Browse Source

Editor: json preview

imwald
Nuša Pukšič 2 weeks ago
parent
commit
e448bfd6d2
  1. 284
      assets/controllers/editor/json-panel_controller.js
  2. 48
      assets/controllers/editor/layout_controller.js
  3. 136
      assets/controllers/nostr/nostr_publish_controller.js
  4. 1
      src/Form/EditorType.php
  5. 10
      src/Twig/Filters.php
  6. 14
      templates/editor/layout.html.twig
  7. 45
      templates/editor/panels/_json.html.twig

284
assets/controllers/editor/json-panel_controller.js

@ -1,284 +0,0 @@ @@ -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 }));
}
}
}

48
assets/controllers/editor/layout_controller.js

@ -1,4 +1,3 @@ @@ -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 { @@ -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 { @@ -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;

136
assets/controllers/nostr/nostr_publish_controller.js

@ -1,4 +1,6 @@ @@ -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) { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 {

1
src/Form/EditorType.php

@ -59,6 +59,7 @@ class EditorType extends AbstractType @@ -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',

10
src/Twig/Filters.php

@ -24,6 +24,7 @@ class Filters extends AbstractExtension @@ -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 @@ -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);
}
}

14
templates/editor/layout.html.twig

@ -277,20 +277,12 @@ @@ -277,20 +277,12 @@
{{ form_end(form) }}
</div>
{# Hidden container for Nostr publishing #}
{# Hidden container for Nostr publishing (no JSON textarea, uses canonical panel) #}
<div style="display: none;" {{ stimulus_controller('nostr--nostr-publish', {
publishUrl: path('api-article-publish')
publishUrl: path('api-article-publish'),
pubkey: app.user ? app.user.userIdentifier|toHex : '<pubkey>'
}) }} data-nostr--nostr-publish-target="form" data-slug="{{ article.slug|default('') }}">
<div data-nostr--nostr-publish-target="status"></div>
<div data-nostr--nostr-publish-target="jsonContainer">
<textarea
style="display: none;"
data-nostr--nostr-publish-target="jsonTextarea"
data-action="input->nostr--nostr-publish#onJsonInput"
></textarea>
</div>
<button style="display: none;" data-nostr--nostr-publish-target="jsonToggle"></button>
<span style="display: none;" data-nostr--nostr-publish-target="jsonDirtyHint"></span>
<button style="display: none;" data-nostr--nostr-publish-target="publishButton"></button>
</div>
</main>

45
templates/editor/panels/_json.html.twig

@ -1,32 +1,35 @@ @@ -1,32 +1,35 @@
<div class="panel-section" data-controller="editor--json-panel">
{# <div class="mb-3">#}
{# <button#}
{# type="button"#}
{# class="btn btn-sm btn-primary w-100"#}
{# data-action="click->editor--json-panel#regenerateJson"#}
{# >#}
{# Rebuild from form#}
{# </button>#}
{# </div>#}
<div class="panel-section"
data-controller="nostr--nostr-publish"
data-nostr--nostr-publish-pubkey-value="{% if app.user %}{{ app.user.userIdentifier|toHex }}{% else %}&lt;pubkey&gt;{% endif %}">
<div class="mb-3 d-flex align-items-center justify-content-between">
<button
type="button"
class="btn btn-sm btn-primary"
data-action="click->nostr--nostr-publish#regenerateJsonPreview"
>
Rebuild from form
</button>
<span class="ms-2 text-muted" data-nostr--nostr-publish-target="jsonTimestamp"></span>
</div>
<div class="json-editor-container">
<div class="json-editor-container" data-nostr--nostr-publish-target="jsonContainer">
<textarea
id="json-editor-textarea"
class="json-textarea"
data-editor--json-panel-target="jsonTextarea"
data-nostr--nostr-publish-target="jsonTextarea"
data-action="input->nostr--nostr-publish#onJsonInput"
rows="20"
spellcheck="false"
>{{ article.raw is defined and article.raw ? article.raw|json_encode(constant('JSON_PRETTY_PRINT')) : '' }}</textarea>
<div class="json-status" data-editor--json-panel-target="status"></div>
<div class="json-status" data-nostr--nostr-publish-target="status"></div>
</div>
{# <div class="panel-help mt-2">#}
{# <small>#}
{# <strong>Required fields:</strong> kind, created_at, tags, content, pubkey<br>#}
{# <span class="text-warning" data-editor--json-panel-target="dirtyHint" style="display:none">#}
{# JSON modified - will override form values#}
{# </span>#}
{# </small>#}
{# </div>#}
<div class="panel-help mt-2">
<small>
<strong>Required fields:</strong> kind, created_at, tags, content, pubkey<br>
<span class="text-warning" data-nostr--nostr-publish-target="jsonDirtyHint" style="display:none">
JSON modified - will override form values
</span>
</small>
</div>
</div>

Loading…
Cancel
Save