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 })); } } }