From e448bfd6d21e9ea035101ad4707131772cca8ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Tue, 30 Dec 2025 16:47:14 +0100 Subject: [PATCH] Editor: json preview --- .../editor/json-panel_controller.js | 284 ------------------ .../controllers/editor/layout_controller.js | 48 +-- .../nostr/nostr_publish_controller.js | 136 ++++++--- src/Form/EditorType.php | 1 + src/Twig/Filters.php | 10 + templates/editor/layout.html.twig | 14 +- templates/editor/panels/_json.html.twig | 45 +-- 7 files changed, 139 insertions(+), 399 deletions(-) delete mode 100644 assets/controllers/editor/json-panel_controller.js diff --git a/assets/controllers/editor/json-panel_controller.js b/assets/controllers/editor/json-panel_controller.js deleted file mode 100644 index 6e5506a..0000000 --- a/assets/controllers/editor/json-panel_controller.js +++ /dev/null @@ -1,284 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import { EditorView, basicSetup } from 'codemirror'; -import { json } from '@codemirror/lang-json'; - -export default class extends Controller { - static targets = ['jsonTextarea', 'status', 'dirtyHint']; - - connect() { - console.log('JSON panel controller connected'); - this.isDirty = false; - - // Listen for the custom event from the Nostr publish controller - document.addEventListener('nostr-json-ready', this.handleNostrJsonReady.bind(this)); - - // Listen for changes in the markdown textarea - const md = this.getMarkdownTextarea(); - if (md) { - md.addEventListener('input', this.handleMarkdownInput.bind(this)); - } - - // Load initial JSON from the Nostr publish controller - this.loadInitialJson(); - - this.textarea = this.jsonTextareaTarget; - // Only initialize CodeMirror if not already done - if (!this.textarea._codemirror) { - this.textarea.style.display = 'none'; - this.cmParent = document.createElement('div'); - this.textarea.parentNode.insertBefore(this.cmParent, this.textarea); - this.cmView = new EditorView({ - doc: this.textarea.value, - extensions: [ - basicSetup, json(), - EditorView.lineWrapping, - ], - parent: this.cmParent, - updateListener: (update) => { - if (update.docChanged) { - this.textarea.value = this.cmView.state.doc.toString(); - this.textarea.dispatchEvent(new Event('input', { bubbles: true })); - } - } - }); - this.textarea._codemirror = this.cmView; - } else { - this.cmView = this.textarea._codemirror; - } - - // Prompt to restore from localStorage if available and textarea is empty - if (this.jsonTextareaTarget.value.trim() === '') { - const savedJson = localStorage.getItem('editorState'); - if (savedJson) { - let shouldRestore = window.confirm('A draft was found in your browser. Do you want to restore it?'); - if (shouldRestore) { - try { - const parsedJson = JSON.parse(savedJson); // Validate JSON - this.jsonTextareaTarget.value = savedJson; - if (this.cmView) { - this.cmView.dispatch({ - changes: {from: 0, to: this.cmView.state.doc.length, insert: savedJson} - }); - } - this.populateFormFieldsFromJson(parsedJson); - } catch (e) { - // Ignore corrupt JSON - localStorage.removeItem('editorState'); - } - } else { - localStorage.removeItem('editorState'); - } - } - } - // Periodic save every 10 seconds - this._saveInterval = setInterval(() => { - if (this.hasJsonTextareaTarget) { - const value = this.jsonTextareaTarget.value; - try { - JSON.parse(value); // Only save valid JSON - localStorage.setItem('editorState', value); - } catch (e) { - // Do not save invalid JSON - } - } - }, 10000); - } - - disconnect() { - // Clean up event listener - document.removeEventListener('nostr-json-ready', this.handleNostrJsonReady.bind(this)); - const md = this.getMarkdownTextarea(); - if (md) { - md.removeEventListener('input', this.handleMarkdownInput.bind(this)); - } - if (this.cmView) this.cmView.destroy(); - if (this.cmParent && this.cmParent.parentNode) { - this.cmParent.parentNode.removeChild(this.cmParent); - } - this.textarea.style.display = ''; - this.textarea._codemirror = null; - } - - handleMarkdownInput() { - // When markdown changes, update the JSON content field and panel - this.updateJsonContentFromMarkdown(); - } - - updateJsonContentFromMarkdown() { - if (!this.hasJsonTextareaTarget) return; - let json; - try { - json = JSON.parse(this.jsonTextareaTarget.value); - } catch (e) { - return; // Don't update if JSON is invalid - } - const md = this.getMarkdownTextarea(); - if (md) { - json.content = md.value; - this.jsonTextareaTarget.value = JSON.stringify(json, null, 2); - this.formatJson(); - } - } - - getMarkdownTextarea() { - // Try common selectors for the markdown textarea - return document.querySelector('#editor_content, textarea[name="editor[content]"]'); - } - - handleNostrJsonReady(event) { - const nostrController = this.getNostrPublishController(); - if (nostrController && nostrController.hasJsonTextareaTarget && this.hasJsonTextareaTarget) { - this.jsonTextareaTarget.value = nostrController.jsonTextareaTarget.value; - // Update CodeMirror document to match textarea - if (this.cmView) { - this.cmView.dispatch({ - changes: {from: 0, to: this.cmView.state.doc.length, insert: this.jsonTextareaTarget.value} - }); - } - this.updateJsonContentFromMarkdown(); - this.formatJson(); - this.isDirty = false; - this.updateDirtyHint(); - this.showStatus('JSON updated', 'success'); - } - } - - loadInitialJson() { - // Wait a bit for the Nostr publish controller to initialize - setTimeout(() => { - const nostrController = this.getNostrPublishController(); - if (nostrController && nostrController.hasJsonTextareaTarget) { - const json = nostrController.jsonTextareaTarget.value; - if (json && this.hasJsonTextareaTarget) { - this.jsonTextareaTarget.value = json; - // Update CodeMirror document to match textarea - if (this.cmView) { - this.cmView.dispatch({ - changes: {from: 0, to: this.cmView.state.doc.length, insert: this.jsonTextareaTarget.value} - }); - } - this.updateJsonContentFromMarkdown(); - this.formatJson(); - } - } - }, 500); - } - - regenerateJson() { - const nostrController = this.getNostrPublishController(); - if (nostrController && typeof nostrController.regenerateJsonPreview === 'function') { - nostrController.regenerateJsonPreview(); - - // Copy the regenerated JSON to our textarea - setTimeout(() => { - if (nostrController.hasJsonTextareaTarget && this.hasJsonTextareaTarget) { - this.jsonTextareaTarget.value = nostrController.jsonTextareaTarget.value; - // Update CodeMirror document to match textarea - if (this.cmView) { - this.cmView.dispatch({ - changes: {from: 0, to: this.cmView.state.doc.length, insert: this.jsonTextareaTarget.value} - }); - } - this.formatJson(); - this.isDirty = false; - this.updateDirtyHint(); - this.showStatus('JSON rebuilt from form', 'success'); - } - }, 100); - } - } - - onJsonInput(event) { - this.isDirty = true; - this.updateDirtyHint(); - this.validateJson(); - - // Sync to the hidden Nostr publish textarea - const nostrController = this.getNostrPublishController(); - if (nostrController && nostrController.hasJsonTextareaTarget) { - nostrController.jsonTextareaTarget.value = event.target.value; - } - } - - validateJson() { - if (!this.hasJsonTextareaTarget) return; - - try { - const json = JSON.parse(this.jsonTextareaTarget.value); - const required = ['kind', 'created_at', 'tags', 'content', 'pubkey']; - const missing = required.filter(field => !(field in json)); - - if (missing.length > 0) { - this.showStatus(`Missing: ${missing.join(', ')}`, 'warning'); - } else { - this.showStatus('Valid JSON', 'success'); - } - } catch (e) { - this.showStatus('Invalid JSON', 'error'); - } - } - - formatJson() { - if (!this.hasJsonTextareaTarget) return; - - try { - const json = JSON.parse(this.jsonTextareaTarget.value); - this.jsonTextareaTarget.value = JSON.stringify(json, null, 2); - this.showStatus('Formatted', 'success'); - } catch (e) { - // Silently fail if JSON is invalid - } - } - - showStatus(message, type = 'info') { - if (!this.hasStatusTarget) return; - - this.statusTarget.textContent = message; - this.statusTarget.className = `json-status json-status--${type}`; - - setTimeout(() => { - if (this.hasStatusTarget) { - this.statusTarget.textContent = ''; - this.statusTarget.className = 'json-status'; - } - }, 3000); - } - - updateDirtyHint() { - if (this.hasDirtyHintTarget) { - this.dirtyHintTarget.style.display = this.isDirty ? 'inline' : 'none'; - } - } - - getNostrPublishController() { - const element = document.querySelector('[data-controller*="nostr--nostr-publish"]'); - if (!element) return null; - - return this.application.getControllerForElementAndIdentifier( - element, - 'nostr--nostr-publish' - ); - } - - // Populate form fields (markdown, title, etc.) from JSON - populateFormFieldsFromJson(json) { - // Markdown content - const md = this.getMarkdownTextarea(); - if (md && json.content !== undefined) { - md.value = json.content; - md.dispatchEvent(new Event('input', { bubbles: true })); - } - // Title (example: input[name="editor[title]"]) - const titleInput = document.querySelector('input[name="editor[title]"]'); - if (titleInput && json.title !== undefined) { - titleInput.value = json.title; - titleInput.dispatchEvent(new Event('input', { bubbles: true })); - } - // Tags (example: input[name="editor[tags]"] or similar) - const tagsInput = document.querySelector('input[name="editor[tags]"]'); - if (tagsInput && json.tags !== undefined && Array.isArray(json.tags)) { - tagsInput.value = json.tags.join(', '); - tagsInput.dispatchEvent(new Event('input', { bubbles: true })); - } - } -} diff --git a/assets/controllers/editor/layout_controller.js b/assets/controllers/editor/layout_controller.js index 229551d..dc9faf2 100644 --- a/assets/controllers/editor/layout_controller.js +++ b/assets/controllers/editor/layout_controller.js @@ -1,4 +1,3 @@ -// assets/controllers/editor/layout_controller.js import {Controller} from '@hotwired/stimulus'; import { deltaToMarkdown, markdownToDelta } from './conversion.js'; @@ -41,9 +40,7 @@ export default class extends Controller { // Listen for content changes from Quill or Markdown this.element.addEventListener('content:changed', () => { - this.updatePreview(); - this.updateJsonCode(); - // Do NOT update Quill from Markdown here; only do so on explicit mode switch + this.updatePreview().then(r => console.log('Preview updated after content change', r)); }); } @@ -112,53 +109,14 @@ export default class extends Controller { this.state.active_source = 'quill'; this.updateQuillEditor(); } else if (mode === 'preview') { - this.updatePreview(); + this.updatePreview().then(r => console.log('Preview updated', r)); } else if (mode === 'json') { - this.updateJsonCode(); + // Not doing anything here for now } this.persistState(); this.emitContentChanged(); } - updateJsonCode() { - // Fill the JSON code block with the latest JSON event and highlight - if (!this.hasJsonCodeTarget) return; - let json = ''; - const nostrController = this.application.getControllerForElementAndIdentifier( - this.element.querySelector('[data-controller*="nostr--nostr-publish"]'), - 'nostr--nostr-publish' - ); - if (nostrController && nostrController.hasJsonTextareaTarget) { - json = nostrController.jsonTextareaTarget.value; - } - try { - json = JSON.stringify(JSON.parse(json), null, 2); - } catch (e) { - // If not valid JSON, show as-is - } - this.jsonCodeTarget.textContent = json || 'No JSON event available.'; - } - - updateMarkdown() { - // Get title from form - const titleInput = this.element.querySelector('input[name*="[title]"]'); - if (titleInput && this.hasMarkdownTitleTarget) { - this.markdownTitleTarget.value = titleInput.value || ''; - } - - // Get markdown from Quill controller - const markdownInput = this.element.querySelector('textarea[name="editor[content]"]'); - const markdown = markdownInput ? markdownInput.value || '' : ''; - - // Set code block content and highlight - if (this.hasMarkdownCodeTarget) { - this.markdownCodeTarget.textContent = markdown; - if (window.Prism && Prism.highlightElement) { - Prism.highlightElement(this.markdownCodeTarget); - } - } - } - async updatePreview() { if (!this.hasPreviewBodyTarget) return; diff --git a/assets/controllers/nostr/nostr_publish_controller.js b/assets/controllers/nostr/nostr_publish_controller.js index 4d258d9..421f5bc 100644 --- a/assets/controllers/nostr/nostr_publish_controller.js +++ b/assets/controllers/nostr/nostr_publish_controller.js @@ -1,4 +1,6 @@ import { Controller } from '@hotwired/stimulus'; +import { EditorView, basicSetup } from 'codemirror'; +import { json } from '@codemirror/lang-json'; // Inline utility functions (simplified versions) function buildAdvancedTags(metadata) { @@ -82,7 +84,9 @@ function validateAdvancedMetadata(metadata) { } export default class extends Controller { - static targets = ['form', 'publishButton', 'status', 'jsonContainer', 'jsonTextarea', 'jsonToggle', 'jsonDirtyHint']; + static targets = [ + 'form', 'publishButton', 'status', 'jsonContainer', 'jsonTextarea', 'jsonDirtyHint', 'jsonTimestamp' + ]; static values = { publishUrl: String }; @@ -96,48 +100,109 @@ export default class extends Controller { // Track whether JSON has been manually edited this.jsonEdited = false; - } - // Toggle JSON preview visibility. If opening and empty, generate from form. - toggleJsonPreview() { - if (!this.hasJsonContainerTarget) return; - const wasHidden = this.jsonContainerTarget.hasAttribute('hidden'); - if (wasHidden) { - // opening - if (!this.jsonEdited && (!this.hasJsonTextareaTarget || !this.jsonTextareaTarget.value.trim())) { - this.regenerateJsonPreview(); + // Setup CodeMirror for JSON textarea (syntax highlighting) + if (this.hasJsonTextareaTarget) { + this.textarea = this.jsonTextareaTarget; + if (!this.textarea._codemirror) { + this.textarea.style.display = 'none'; + this.cmParent = document.createElement('div'); + this.textarea.parentNode.insertBefore(this.cmParent, this.textarea); + console.log('[nostr-publish] Initializing CodeMirror for JSON textarea', this.textarea.value); + this.cmView = new EditorView({ + doc: this.textarea.value, + extensions: [ + basicSetup, json(), + EditorView.lineWrapping, + EditorView.updateListener.of((v) => { + console.log('[nostr-publish] CodeMirror update (alt):', v); + if (v.docChanged) { + const newValue = this.cmView.state.doc.toString(); + if (this.textarea.value !== newValue) { + this.textarea.value = newValue; + // Manually dispatch an input event to ensure listeners are triggered + this.textarea.dispatchEvent(new Event('input', { bubbles: true })); + // Mark JSON as edited + this.jsonEdited = true; + if (this.hasJsonDirtyHintTarget) { + this.jsonDirtyHintTarget.style.display = 'block'; + } + } + } + }) + ], + parent: this.cmParent, + updateListener: (update) => { + console.log('[nostr-publish] CodeMirror update:', update); + if (update.docChanged) { + const newValue = this.cmView.state.doc.toString(); + if (this.textarea.value !== newValue) { + this.textarea.value = newValue; + // Manually dispatch an input event to ensure listeners are triggered + this.textarea.dispatchEvent(new Event('input', { bubbles: true })); + // Mark JSON as edited + this.jsonEdited = true; + if (this.hasJsonDirtyHintTarget) { + this.jsonDirtyHintTarget.style.display = 'block'; + } + } + } + } + }); + this.textarea._codemirror = this.cmView; + } else { + this.cmView = this.textarea._codemirror; } - this.jsonContainerTarget.removeAttribute('hidden'); - if (this.hasJsonToggleTarget) this.jsonToggleTarget.textContent = 'Hide raw event JSON'; - } else { - // closing, keep content as-is - this.jsonContainerTarget.setAttribute('hidden', ''); - if (this.hasJsonToggleTarget) this.jsonToggleTarget.textContent = 'Show raw event JSON'; } + + this.lastJsonGenerated = null; } - // Rebuild JSON from form data (clears edited flag) async regenerateJsonPreview() { try { const formData = this.collectFormData(); const nostrEvent = await this.createNostrEvent(formData); const pretty = JSON.stringify(nostrEvent, null, 2); - if (this.hasJsonTextareaTarget) this.jsonTextareaTarget.value = pretty; + if (this.hasJsonTextareaTarget) { + this.jsonTextareaTarget.value = pretty; + if (this.cmView) { + this.cmView.dispatch({ + changes: {from: 0, to: this.cmView.state.doc.length, insert: pretty} + }); + } + } this.jsonEdited = false; if (this.hasJsonDirtyHintTarget) this.jsonDirtyHintTarget.style.display = 'none'; - // Dispatch event to notify others that JSON is ready - this.element.dispatchEvent(new CustomEvent('nostr-json-ready', { bubbles: true })); + this.lastJsonGenerated = new Date(); + this.updateJsonTimestamp(); } catch (e) { this.showError('Could not build event JSON: ' + (e?.message || e)); } } + updateJsonTimestamp() { + if (this.hasJsonTimestampTarget && this.lastJsonGenerated) { + const ts = this.lastJsonGenerated; + this.jsonTimestampTarget.textContent = `Last generated: ${ts.toLocaleString()}`; + } + } + // Mark JSON as edited on user input onJsonInput() { this.jsonEdited = true; if (this.hasJsonDirtyHintTarget) this.jsonDirtyHintTarget.style.display = ''; } + getCurrentJson() { + if (this.cmView) { + return this.cmView.state.doc.toString(); + } + if (this.hasJsonTextareaTarget) { + return this.jsonTextareaTarget.value; + } + return ''; + } + async publish(event = null) { if (event) { event.preventDefault(); @@ -157,28 +222,18 @@ export default class extends Controller { this.showStatus('Preparing article for signing...'); try { - // Collect form data (always, for fallback and backend extras) - const formData = this.collectFormData(); - - // Validate required fields if no JSON override - if (!this.jsonEdited) { - if (!formData.title || !formData.content) { - throw new Error('Title and content are required'); - } - } - - // Create or use overridden Nostr event + // Use canonical CodeMirror JSON for publishing let nostrEvent; - if (this.jsonEdited && this.hasJsonTextareaTarget && this.jsonTextareaTarget.value.trim()) { + const jsonString = this.getCurrentJson(); + if (jsonString.trim()) { try { - const parsed = JSON.parse(this.jsonTextareaTarget.value); - // Ensure required fields exist; supplement from form when missing - nostrEvent = this.applyEventDefaults(parsed, formData); + nostrEvent = JSON.parse(jsonString); } catch (e) { throw new Error('Invalid JSON in raw event area: ' + (e?.message || e)); } } else { - nostrEvent = await this.createNostrEvent(formData); + // Fallback: regenerate from form data + nostrEvent = await this.createNostrEvent(this.collectFormData()); } // Ensure pubkey present before signing @@ -194,13 +249,13 @@ export default class extends Controller { this.showStatus('Publishing article...'); // Send to backend - await this.sendToBackend(signedEvent, formData); + await this.sendToBackend(signedEvent, this.collectFormData()); this.showSuccess('Article published successfully!'); // Optionally redirect after successful publish setTimeout(() => { - window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`; + window.location.href = `/article/d/${encodeURIComponent(nostrEvent.tags?.find(t => t[0] === 'd')?.[1] || '')}`; }, 2000); } catch (error) { @@ -357,6 +412,11 @@ export default class extends Controller { } async createNostrEvent(formData) { + // TODO This logic needs to be updated to take care of three distinct cases: + // 1. user not logged in: generate event from form data with placeholder pubkey + // 2. user logged in with extension: get pubkey from extension and generate event + // 3. user logged in with signer: get pubkey from signer and generate event + // ----------------------------------------------------------------------------- // Get user's public key if available (preview can work without it) let pubkey = ''; try { diff --git a/src/Form/EditorType.php b/src/Form/EditorType.php index 832aadc..24cb33c 100644 --- a/src/Form/EditorType.php +++ b/src/Form/EditorType.php @@ -59,6 +59,7 @@ class EditorType extends AbstractType 'label' => 'Add client tag to article (Decent Newsroom)', 'required' => false, 'mapped' => false, + 'data' => true, ]) ->add('isDraft', CheckboxType::class, [ 'label' => 'Save as draft', diff --git a/src/Twig/Filters.php b/src/Twig/Filters.php index 02207fe..1bf83af 100644 --- a/src/Twig/Filters.php +++ b/src/Twig/Filters.php @@ -24,6 +24,7 @@ class Filters extends AbstractExtension new TwigFilter('nEncode', [$this, 'nEncode']), new TwigFilter('naddrEncode', [$this, 'naddrEncode']), new TwigFilter('toNpub', [$this, 'toNpub']), + new TwigFilter('toHex', [$this, 'toHex']), ]; } @@ -101,4 +102,13 @@ class Filters extends AbstractExtension $key = new Key(); return $key->convertPublicKeyToBech32($hexPubKey); } + + /** + * @throws Exception + */ + public function toHex(string $npub): string + { + $key = new Key(); + return $key->convertToHex($npub); + } } diff --git a/templates/editor/layout.html.twig b/templates/editor/layout.html.twig index a6d5005..224b6a5 100644 --- a/templates/editor/layout.html.twig +++ b/templates/editor/layout.html.twig @@ -277,20 +277,12 @@ {{ form_end(form) }} - {# Hidden container for Nostr publishing #} + {# Hidden container for Nostr publishing (no JSON textarea, uses canonical panel) #}
' }) }} data-nostr--nostr-publish-target="form" data-slug="{{ article.slug|default('') }}">
-
- -
- -
diff --git a/templates/editor/panels/_json.html.twig b/templates/editor/panels/_json.html.twig index 7f6ee96..653855c 100644 --- a/templates/editor/panels/_json.html.twig +++ b/templates/editor/panels/_json.html.twig @@ -1,32 +1,35 @@ -
-{#
#} -{# #} -{# Rebuild from form#} -{# #} -{#
#} +
+
+ + +
-
+
-
+
-{#
#} -{# #} -{# Required fields: kind, created_at, tags, content, pubkey
#} -{# #} -{#
#} -{#
#} +
+ + Required fields: kind, created_at, tags, content, pubkey
+ +
+