From a2fa3537dc605170cfe84b799028585357eed57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Thu, 25 Dec 2025 12:56:41 +0100 Subject: [PATCH] Editor: sync panes --- .../editor/json-panel_controller.js | 58 ++++++++++++++++++- .../controllers/editor/layout_controller.js | 33 ++++++++++- .../editor/markdown-sync_controller.js | 29 +++++++++- .../nostr/nostr_publish_controller.js | 5 +- .../publishing/quill_controller.js | 34 ++++++----- templates/editor/layout.html.twig | 12 ++-- 6 files changed, 145 insertions(+), 26 deletions(-) diff --git a/assets/controllers/editor/json-panel_controller.js b/assets/controllers/editor/json-panel_controller.js index e4b0c74..86c2317 100644 --- a/assets/controllers/editor/json-panel_controller.js +++ b/assets/controllers/editor/json-panel_controller.js @@ -7,10 +7,66 @@ export default class extends Controller { 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(); } + 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)); + } + } + + 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; + 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(() => { @@ -19,6 +75,7 @@ export default class extends Controller { const json = nostrController.jsonTextareaTarget.value; if (json && this.hasJsonTextareaTarget) { this.jsonTextareaTarget.value = json; + this.updateJsonContentFromMarkdown(); this.formatJson(); } } @@ -115,4 +172,3 @@ export default class extends Controller { ); } } - diff --git a/assets/controllers/editor/layout_controller.js b/assets/controllers/editor/layout_controller.js index 07c5e72..8f7ce2c 100644 --- a/assets/controllers/editor/layout_controller.js +++ b/assets/controllers/editor/layout_controller.js @@ -41,6 +41,18 @@ export default class extends Controller { } } } + + // Listen for content changes from Quill or Markdown + this.element.addEventListener('content:changed', () => { + this.updatePreview(); + // If JSON pane is present, update it as well + if (this.hasJsonPaneTarget) { + const jsonTextarea = this.jsonPaneTarget.querySelector('[data-editor--json-panel-target="jsonTextarea"]'); + if (jsonTextarea && window.nostrPublishController && typeof window.nostrPublishController.regenerateJsonPreview === 'function') { + window.nostrPublishController.regenerateJsonPreview(); + } + } + }); } switchMode(event) { @@ -60,6 +72,23 @@ export default class extends Controller { // Update content when switching modes if (mode === 'markdown') { this.updateMarkdown(); + } else if (mode === 'edit') { + // Sync Markdown to Quill when switching to Quill pane + const markdownInput = this.element.querySelector('input[name="editor[content]"]'); + if (markdownInput && window.appQuill) { + if (window.marked) { + window.appQuill.root.innerHTML = window.marked.parse(markdownInput.value || ''); + } else { + // Fallback: use backend endpoint + fetch('/editor/markdown/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ markdown: markdownInput.value || '' }) + }) + .then(resp => resp.ok ? resp.json() : { html: '' }) + .then(data => { window.appQuill.root.innerHTML = data.html || ''; }); + } + } } else if (mode === 'preview') { this.updatePreview(); } else if (mode === 'json') { @@ -75,7 +104,7 @@ export default class extends Controller { } // Get markdown from Quill controller - const markdownInput = this.element.querySelector('input[name="editor[content_md]"]'); + const markdownInput = this.element.querySelector('input[name="editor[content]"]'); const markdown = markdownInput ? markdownInput.value || '' : ''; // Set code block content and highlight @@ -94,7 +123,7 @@ export default class extends Controller { const titleInput = this.element.querySelector('input[name*="[title]"], input[name="editor[title]"]'); const summaryInput = this.element.querySelector('textarea[name*="[summary]"], textarea[name="editor[summary]"]'); const imageInput = this.element.querySelector('input[name*="[image]"], input[name="editor[image]"]'); - const markdownInput = this.element.querySelector('input[name="editor[content_md]"]'); + const markdownInput = this.element.querySelector('input[name="editor[content]"]'); const authorInput = this.element.querySelector('input[name*="[author]"]'); const dateInput = this.element.querySelector('input[name*="[publishedAt]"]') || this.element.querySelector('input[name*="[createdAt]"]'); diff --git a/assets/controllers/editor/markdown-sync_controller.js b/assets/controllers/editor/markdown-sync_controller.js index 88edcb0..4b1c462 100644 --- a/assets/controllers/editor/markdown-sync_controller.js +++ b/assets/controllers/editor/markdown-sync_controller.js @@ -6,6 +6,10 @@ export default class extends Controller { connect() { this.updateMarkdown(); this.hiddenTarget.addEventListener("input", this.updateMarkdown.bind(this)); + // Also trigger a custom event for layout controller + this.hiddenTarget.addEventListener("input", () => { + this.element.dispatchEvent(new CustomEvent('content:changed', { bubbles: true })); + }); // Observe programmatic changes to the value attribute this.observer = new MutationObserver(() => this.updateMarkdown()); this.observer.observe(this.hiddenTarget, { attributes: true, attributeFilter: ["value"] }); @@ -16,8 +20,29 @@ export default class extends Controller { if (this.observer) this.observer.disconnect(); } - updateMarkdown() { + async updateMarkdown() { this.codeTarget.textContent = this.hiddenTarget.value; + // Sync Markdown to Quill (content_html) + if (window.appQuill) { + let html = ''; + if (window.marked) { + html = window.marked.parse(this.hiddenTarget.value || ''); + } else { + // Fallback: use backend endpoint + try { + const resp = await fetch('/editor/markdown/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ markdown: this.hiddenTarget.value || '' }) + }); + if (resp.ok) { + const data = await resp.json(); + html = data.html || ''; + } + } catch (e) { html = ''; } + } + // Set Quill content from HTML (replace contents) + window.appQuill.root.innerHTML = html; + } } } - diff --git a/assets/controllers/nostr/nostr_publish_controller.js b/assets/controllers/nostr/nostr_publish_controller.js index 89c4f4f..bfc8b84 100644 --- a/assets/controllers/nostr/nostr_publish_controller.js +++ b/assets/controllers/nostr/nostr_publish_controller.js @@ -125,6 +125,8 @@ export default class extends Controller { if (this.hasJsonTextareaTarget) this.jsonTextareaTarget.value = 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 })); } catch (e) { this.showError('Could not build event JSON: ' + (e?.message || e)); } @@ -270,7 +272,7 @@ export default class extends Controller { const fd = new FormData(form); // Prefer the Markdown field populated by the Quill controller - const md = fd.get('editor[content_md]'); + const md = fd.get('editor[content]'); let html = fd.get('editor[content]') || fd.get('content') || ''; // Final content: use MD if present, otherwise convert HTML -> MD @@ -530,4 +532,3 @@ export default class extends Controller { } } } - diff --git a/assets/controllers/publishing/quill_controller.js b/assets/controllers/publishing/quill_controller.js index 3716813..146874c 100644 --- a/assets/controllers/publishing/quill_controller.js +++ b/assets/controllers/publishing/quill_controller.js @@ -181,26 +181,28 @@ export default class extends Controller { this.quill.on('text-change', (delta, old, source) => { if (source === 'user') highlightAll(); this.syncHiddenAsHtml(); + // --- Quill → Markdown sync --- + if (this.hasMarkdownTarget) { + if (window.deltaToMarkdown) { + const md = window.deltaToMarkdown(this.quill.getContents()); + this.markdownTarget.value = md; + // Trigger event for reactivity + this.markdownTarget.dispatchEvent(new Event('input', { bubbles: true })); + // Also trigger a custom event for layout controller + this.element.dispatchEvent(new CustomEvent('content:changed', { bubbles: true })); + } + } }); - 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()); + // Expose a method to set Quill content from HTML + window.setQuillHtml = (html) => { + this.quill.root.innerHTML = html; }; - // 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) + // safety: also refresh HTML right before a real submit (if any) const form = this.element.closest('form'); if (form) { - form.addEventListener('submit', () => sync()); + form.addEventListener('submit', () => this.syncHiddenAsHtml()); } } @@ -454,3 +456,7 @@ function deltaToMarkdown(delta) { out = out.replace(/\n{3,}/g, '\n\n'); return out.trim(); } + +// Make deltaToMarkdown globally available +window.deltaToMarkdown = deltaToMarkdown; + diff --git a/templates/editor/layout.html.twig b/templates/editor/layout.html.twig index 43a6c59..8fb9ba0 100644 --- a/templates/editor/layout.html.twig +++ b/templates/editor/layout.html.twig @@ -8,7 +8,7 @@
{{ value|raw }}
- + {% endblock %} @@ -186,8 +186,8 @@ }) }} - {# QuillJS editor container #} - {{ form_row(form.content, {'label': false}) }} + {# QuillJS editor container, now using content_html #} + {{ form_row(form.content_html, {'label': false}) }} {# Hidden field for draft status - controlled by Save Draft / Publish buttons #}
@@ -210,7 +210,7 @@ data-editor--layout-target="markdownPane" data-controller="editor--markdown-sync" > - {# Markdown editor #} + {# Markdown editor - source of truth #}
-
+ {{ form_row(form.content, {'label': false, 'attr': {'class': 'form-control editor-md-field', 'data-editor--markdown-sync-target': 'hidden'}}) }} + + {# JS should sync changes in content to content_html (Quill) #}