You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
374 lines
15 KiB
374 lines
15 KiB
// assets/controllers/editor/layout_controller.js |
|
import {Controller} from '@hotwired/stimulus'; |
|
import { deltaToMarkdown, markdownToDelta } from './conversion.js'; |
|
|
|
export default class extends Controller { |
|
static targets = [ |
|
'modeTab', 'editPane', 'markdownPane', 'jsonPane', 'previewPane', |
|
'previewBody', 'previewTitle', |
|
'previewSummary', 'previewImage', 'previewImagePlaceholder', 'previewAuthor', 'previewDate', |
|
'markdownEditor', 'markdownTitle', 'markdownCode', 'status', |
|
'saveDraftSubmit', 'publishSubmit', 'jsonCode' |
|
]; |
|
|
|
connect() { |
|
console.log('Editor layout controller connected'); |
|
this.autoSaveTimer = null; |
|
|
|
// --- Editor State Object --- |
|
// See documentation/Editor/Reactivity-and-state-management.md |
|
this.state = { |
|
active_source: 'md', // Markdown is authoritative on load |
|
content_delta: null, // Quill Delta (object) |
|
content_NMD: '', // Markdown string |
|
content_event_json: {} // Derived event JSON |
|
}; |
|
this.hydrateState(); |
|
this.updateMarkdownEditor(); |
|
this.updateQuillEditor(); |
|
|
|
// Live preview for summary and image fields |
|
const summaryInput = this.element.querySelector('textarea[name*="[summary]"], textarea[name="editor[summary]"]'); |
|
const imageInput = this.element.querySelector('input[name*="[image]"], input[name="editor[image]"]'); |
|
if (summaryInput) { |
|
summaryInput.addEventListener('input', () => this.updatePreview()); |
|
summaryInput.addEventListener('change', () => this.updatePreview()); |
|
} |
|
if (imageInput) { |
|
imageInput.addEventListener('input', () => this.updatePreview()); |
|
imageInput.addEventListener('change', () => this.updatePreview()); |
|
} |
|
|
|
// 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 |
|
}); |
|
} |
|
|
|
hydrateState() { |
|
// Always hydrate from Markdown (content_NMD) on load |
|
// (Assume hidden field with ID: contentNMD or textarea[name="editor[content]"]) |
|
let nmd = ''; |
|
const nmdField = document.getElementById('contentNMD'); |
|
if (nmdField && nmdField.value) { |
|
nmd = nmdField.value; |
|
} else { |
|
// Fallback: try textarea |
|
const mdTextarea = this.element.querySelector('textarea[name="editor[content]"]'); |
|
if (mdTextarea) nmd = mdTextarea.value; |
|
} |
|
this.state.content_NMD = nmd; |
|
this.state.content_delta = this.nmdToDelta(nmd); |
|
this.state.active_source = 'md'; |
|
} |
|
|
|
persistState() { |
|
// Save state to localStorage and hidden fields |
|
localStorage.setItem('editorState', JSON.stringify(this.state)); |
|
const deltaField = document.getElementById('contentDelta'); |
|
const nmdField = document.getElementById('contentNMD'); |
|
if (deltaField) deltaField.value = JSON.stringify(this.state.content_delta || {}); |
|
if (nmdField) nmdField.value = this.state.content_NMD || ''; |
|
} |
|
|
|
// --- Tab Switching Logic --- |
|
switchMode(event) { |
|
const mode = event.currentTarget.dataset.mode; |
|
|
|
// Update tab states |
|
this.modeTabTargets.forEach(tab => { |
|
tab.classList.toggle('is-active', tab.dataset.mode === mode); |
|
}); |
|
|
|
// Toggle panes - hide all, then show the selected one |
|
this.editPaneTarget.classList.toggle('is-hidden', mode !== 'edit'); |
|
this.markdownPaneTarget.classList.toggle('is-hidden', mode !== 'markdown'); |
|
this.jsonPaneTarget.classList.toggle('is-hidden', mode !== 'json'); |
|
this.previewPaneTarget.classList.toggle('is-hidden', mode !== 'preview'); |
|
|
|
// Update content when switching modes |
|
if (mode === 'markdown' && this.state.active_source === 'quill') { |
|
// Convert Delta to NMD |
|
this.state.content_NMD = this.deltaToNMD(this.state.content_delta); |
|
this.state.active_source = 'md'; |
|
this.updateMarkdownEditor(); |
|
} else if (mode === 'edit') { |
|
// Always convert latest Markdown to Delta and update Quill |
|
// (regardless of previous active_source) |
|
// Get latest Markdown from textarea or CodeMirror |
|
let nmd = ''; |
|
const markdownInput = this.element.querySelector('textarea[name="editor[content]"]'); |
|
if (markdownInput && markdownInput._codemirror) { |
|
nmd = markdownInput._codemirror.state.doc.toString(); |
|
} else if (markdownInput) { |
|
nmd = markdownInput.value; |
|
} else { |
|
nmd = this.state.content_NMD; |
|
} |
|
this.state.content_NMD = nmd; |
|
this.state.content_delta = this.nmdToDelta(nmd); |
|
this.state.active_source = 'quill'; |
|
this.updateQuillEditor(); |
|
} else if (mode === 'preview') { |
|
this.updatePreview(); |
|
} else if (mode === 'json') { |
|
this.updateJsonCode(); |
|
} |
|
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; |
|
|
|
// Get title from form |
|
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('textarea[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]"]'); |
|
|
|
// Title |
|
const title = titleInput ? titleInput.value.trim() : ''; |
|
if (this.hasPreviewTitleTarget) { |
|
this.previewTitleTarget.textContent = title || 'Article title'; |
|
} |
|
|
|
// Author (placeholder logic) |
|
if (this.hasPreviewAuthorTarget) { |
|
let author = authorInput ? authorInput.value.trim() : ''; |
|
this.previewAuthorTarget.textContent = author || 'Author'; |
|
} |
|
|
|
// Date (placeholder logic) |
|
if (this.hasPreviewDateTarget) { |
|
const now = new Date(); |
|
this.previewDateTarget.textContent = now.toLocaleDateString(undefined, { |
|
year: 'numeric', |
|
month: 'long', |
|
day: 'numeric' |
|
}); |
|
} |
|
|
|
// Summary (always use form value) |
|
const summary = summaryInput ? summaryInput.value.trim() : ''; |
|
if (this.hasPreviewSummaryTarget) { |
|
this.previewSummaryTarget.textContent = summary || 'No summary provided. This is where your article summary will appear.'; |
|
this.previewSummaryTarget.classList.toggle('placeholder', !summary); |
|
} |
|
|
|
// Image (always use form value) |
|
const imageUrl = imageInput ? imageInput.value.trim() : ''; |
|
if (this.hasPreviewImageTarget && this.hasPreviewImagePlaceholderTarget) { |
|
if (imageUrl) { |
|
this.previewImageTarget.src = imageUrl; |
|
this.previewImageTarget.style.display = ''; |
|
this.previewImagePlaceholderTarget.style.display = 'none'; |
|
} else { |
|
this.previewImageTarget.src = ''; |
|
this.previewImageTarget.style.display = 'none'; |
|
this.previewImagePlaceholderTarget.style.display = ''; |
|
} |
|
} |
|
|
|
// Body (markdown to HTML via backend) |
|
let html = '<p><em>Loading preview...</em></p>'; |
|
this.previewBodyTarget.innerHTML = html; |
|
if (markdownInput) { |
|
try { |
|
const response = await fetch('/editor/markdown/preview', { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
'X-Requested-With': 'XMLHttpRequest', |
|
}, |
|
body: JSON.stringify({ markdown: markdownInput.value || '' }) |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
html = data.html || '<p><em>No content yet. Start writing your article!</em></p>'; |
|
} else { |
|
html = '<p><em>Failed to load preview.</em></p>'; |
|
} |
|
} catch (e) { |
|
html = '<p><em>Error loading preview.</em></p>'; |
|
} |
|
this.previewBodyTarget.innerHTML = html; |
|
} else { |
|
this.previewBodyTarget.innerHTML = '<p><em>No content yet. Start writing your article!</em></p>'; |
|
} |
|
} |
|
|
|
saveDraft() { |
|
// Only for mobile actions, not header |
|
alert('[Editor] saveDraft called'); |
|
|
|
// Mark as draft - set checkbox to true |
|
const draftCheckbox = this.element.querySelector('input[name*="[isDraft]"]'); |
|
if (draftCheckbox) { |
|
draftCheckbox.checked = true; |
|
} else { |
|
console.warn('[Editor] Draft checkbox not found'); |
|
} |
|
|
|
// Submit the form |
|
const form = this.element.querySelector('form'); |
|
if (form) { |
|
this.updateStatus('Saving draft...'); |
|
form.requestSubmit(); |
|
console.log('[Editor] Form submitted for draft'); |
|
} else { |
|
console.error('[Editor] Form not found for saveDraft'); |
|
} |
|
} |
|
|
|
publish() { |
|
// Only for mobile actions, not header |
|
alert('[Editor] publish called'); |
|
|
|
// Mark as NOT draft - set checkbox to false |
|
const draftCheckbox = this.element.querySelector('input[name*="[isDraft]"]'); |
|
if (draftCheckbox) { |
|
draftCheckbox.checked = false; |
|
} else { |
|
console.warn('[Editor] Draft checkbox not found'); |
|
} |
|
|
|
// Find the Nostr publish controller and trigger publish |
|
const nostrController = this.application.getControllerForElementAndIdentifier( |
|
this.element.querySelector('[data-controller*="nostr--nostr-publish"]'), |
|
'nostr--nostr-publish' |
|
); |
|
|
|
if (nostrController && typeof nostrController.publish === 'function') { |
|
console.log('[Editor] Nostr publish controller found, calling publish()'); |
|
nostrController.publish(); |
|
} else { |
|
// Fallback: submit the form |
|
const form = this.element.querySelector('form'); |
|
if (form) { |
|
this.updateStatus('Publishing...'); |
|
form.requestSubmit(); |
|
console.log('[Editor] Form submitted for publish'); |
|
} else { |
|
console.error('[Editor] Form not found for publish'); |
|
alert('Could not find publishing controller or form. Please try again.'); |
|
} |
|
} |
|
} |
|
|
|
|
|
updateStatus(message) { |
|
if (this.hasStatusTarget) { |
|
this.statusTarget.textContent = message; |
|
|
|
// Clear status after 3 seconds |
|
setTimeout(() => { |
|
if (this.hasStatusTarget) { |
|
this.statusTarget.textContent = ''; |
|
} |
|
}, 3000); |
|
} |
|
} |
|
|
|
disconnect() { |
|
if (this.autoSaveTimer) { |
|
clearTimeout(this.autoSaveTimer); |
|
} |
|
} |
|
|
|
// --- Content Update Handlers --- |
|
onQuillChange(delta) { |
|
this.state.content_delta = delta; |
|
this.state.active_source = 'quill'; |
|
this.persistState(); |
|
this.emitContentChanged(); |
|
} |
|
onMarkdownChange(nmd) { |
|
this.state.content_NMD = nmd; |
|
this.state.active_source = 'md'; |
|
this.persistState(); |
|
this.emitContentChanged(); |
|
} |
|
|
|
// --- Editor Sync Helpers --- |
|
updateMarkdownEditor() { |
|
// Set Markdown editor value from state.content_NMD |
|
const markdownInput = this.element.querySelector('textarea[name="editor[content]"]'); |
|
if (markdownInput) markdownInput.value = this.state.content_NMD || ''; |
|
// If using CodeMirror, update its doc as well |
|
if (markdownInput && markdownInput._codemirror) { |
|
markdownInput._codemirror.dispatch({ |
|
changes: { from: 0, to: markdownInput._codemirror.state.doc.length, insert: this.state.content_NMD || '' } |
|
}); |
|
} |
|
} |
|
updateQuillEditor() { |
|
// Set Quill editor value from state.content_delta |
|
if (window.appQuill && this.state.content_delta) { |
|
window.appQuill.setContents(this.state.content_delta); |
|
} |
|
} |
|
|
|
// --- Conversion Stubs (implement via DNIR pipeline) --- |
|
deltaToNMD(delta) { |
|
// Use conversion pipeline |
|
return deltaToMarkdown(delta); |
|
} |
|
nmdToDelta(nmd) { |
|
// Use conversion pipeline |
|
console.log('Converting NMD to Delta:', nmd); |
|
console.log('Converted Delta:', markdownToDelta(nmd)); |
|
return markdownToDelta(nmd); |
|
} |
|
|
|
emitContentChanged() { |
|
// Emit a custom event with the new state |
|
this.element.dispatchEvent(new CustomEvent('content:changed', { |
|
detail: { ...this.state }, |
|
bubbles: true |
|
})); |
|
} |
|
}
|
|
|