Browse Source

Editor: sync across panels, syntax highlighting

imwald
Nuša Pukšič 2 weeks ago
parent
commit
184501df79
  1. 33
      assets/controllers/editor/json-panel_controller.js
  2. 21
      assets/controllers/editor/layout_controller.js
  3. 53
      assets/controllers/editor/markdown-sync_controller.js
  4. 8
      assets/styles/editor-layout.css
  5. 78
      importmap.php
  6. 7
      templates/editor/layout.html.twig

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

@ -1,4 +1,6 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
import { EditorView, basicSetup } from 'codemirror';
import { json } from '@codemirror/lang-json';
export default class extends Controller { export default class extends Controller {
static targets = ['jsonTextarea', 'status', 'dirtyHint']; static targets = ['jsonTextarea', 'status', 'dirtyHint'];
@ -18,6 +20,31 @@ export default class extends Controller {
// Load initial JSON from the Nostr publish controller // Load initial JSON from the Nostr publish controller
this.loadInitialJson(); 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;
}
} }
disconnect() { disconnect() {
@ -27,6 +54,12 @@ export default class extends Controller {
if (md) { if (md) {
md.removeEventListener('input', this.handleMarkdownInput.bind(this)); 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() { handleMarkdownInput() {

21
assets/controllers/editor/layout_controller.js

@ -45,6 +45,21 @@ export default class extends Controller {
// Listen for content changes from Quill or Markdown // Listen for content changes from Quill or Markdown
this.element.addEventListener('content:changed', () => { this.element.addEventListener('content:changed', () => {
this.updatePreview(); this.updatePreview();
// Update Quill pane live
const markdownInput = this.element.querySelector('textarea[name="editor[content]"]');
if (markdownInput && window.appQuill) {
if (window.marked) {
window.appQuill.root.innerHTML = window.marked.parse(markdownInput.value || '');
} else {
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 || ''; });
}
}
// If JSON pane is present, update it as well // If JSON pane is present, update it as well
if (this.hasJsonPaneTarget) { if (this.hasJsonPaneTarget) {
const jsonTextarea = this.jsonPaneTarget.querySelector('[data-editor--json-panel-target="jsonTextarea"]'); const jsonTextarea = this.jsonPaneTarget.querySelector('[data-editor--json-panel-target="jsonTextarea"]');
@ -74,7 +89,7 @@ export default class extends Controller {
this.updateMarkdown(); this.updateMarkdown();
} else if (mode === 'edit') { } else if (mode === 'edit') {
// Sync Markdown to Quill when switching to Quill pane // Sync Markdown to Quill when switching to Quill pane
const markdownInput = this.element.querySelector('input[name="editor[content]"]'); const markdownInput = this.element.querySelector('textarea[name="editor[content]"]');
if (markdownInput && window.appQuill) { if (markdownInput && window.appQuill) {
if (window.marked) { if (window.marked) {
window.appQuill.root.innerHTML = window.marked.parse(markdownInput.value || ''); window.appQuill.root.innerHTML = window.marked.parse(markdownInput.value || '');
@ -104,7 +119,7 @@ export default class extends Controller {
} }
// Get markdown from Quill controller // Get markdown from Quill controller
const markdownInput = this.element.querySelector('input[name="editor[content]"]'); const markdownInput = this.element.querySelector('textarea[name="editor[content]"]');
const markdown = markdownInput ? markdownInput.value || '' : ''; const markdown = markdownInput ? markdownInput.value || '' : '';
// Set code block content and highlight // Set code block content and highlight
@ -123,7 +138,7 @@ export default class extends Controller {
const titleInput = this.element.querySelector('input[name*="[title]"], input[name="editor[title]"]'); 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 summaryInput = this.element.querySelector('textarea[name*="[summary]"], textarea[name="editor[summary]"]');
const imageInput = this.element.querySelector('input[name*="[image]"], input[name="editor[image]"]'); const imageInput = this.element.querySelector('input[name*="[image]"], input[name="editor[image]"]');
const markdownInput = this.element.querySelector('input[name="editor[content]"]'); const markdownInput = this.element.querySelector('textarea[name="editor[content]"]');
const authorInput = this.element.querySelector('input[name*="[author]"]'); const authorInput = this.element.querySelector('input[name*="[author]"]');
const dateInput = this.element.querySelector('input[name*="[publishedAt]"]') || this.element.querySelector('input[name*="[createdAt]"]'); const dateInput = this.element.querySelector('input[name*="[publishedAt]"]') || this.element.querySelector('input[name*="[createdAt]"]');

53
assets/controllers/editor/markdown-sync_controller.js

@ -1,39 +1,68 @@
import { Controller } from "@hotwired/stimulus"; import { Controller } from "@hotwired/stimulus";
import { EditorView, basicSetup } from "codemirror";
import { markdown } from "@codemirror/lang-markdown";
export default class extends Controller { export default class extends Controller {
static targets = ["hidden", "code"];
connect() { connect() {
this.textarea = this.element.querySelector(".editor-md-field");
this.codePreview = this.element.querySelector("pre");
// 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,
markdown(),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
this.textarea.value = update.state.doc.toString();
this.updateMarkdown();
this.element.dispatchEvent(new CustomEvent('content:changed', { bubbles: true }));
}
})
],
parent: this.cmParent
});
this.textarea._codemirror = this.cmView;
} else {
this.cmView = this.textarea._codemirror;
}
this.updateMarkdown(); 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 // Observe programmatic changes to the value attribute
this.observer = new MutationObserver(() => this.updateMarkdown()); this.observer = new MutationObserver(() => this.updateMarkdown());
this.observer.observe(this.hiddenTarget, { attributes: true, attributeFilter: ["value"] }); this.observer.observe(this.textarea, { attributes: true, attributeFilter: ["value"] });
} }
disconnect() { disconnect() {
this.hiddenTarget.removeEventListener("input", this.updateMarkdown.bind(this));
if (this.observer) this.observer.disconnect(); if (this.observer) this.observer.disconnect();
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;
} }
async updateMarkdown() { async updateMarkdown() {
this.codeTarget.textContent = this.hiddenTarget.value; if (this.codePreview) {
this.codePreview.textContent = this.textarea.value;
}
// Sync Markdown to Quill (content_html) // Sync Markdown to Quill (content_html)
if (window.appQuill) { if (window.appQuill) {
let html = ''; let html = '';
if (window.marked) { if (window.marked) {
html = window.marked.parse(this.hiddenTarget.value || ''); html = window.marked.parse(this.textarea.value || '');
} else { } else {
// Fallback: use backend endpoint // Fallback: use backend endpoint
try { try {
const resp = await fetch('/editor/markdown/preview', { const resp = await fetch('/editor/markdown/preview', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ markdown: this.hiddenTarget.value || '' }) body: JSON.stringify({ markdown: this.textarea.value || '' })
}); });
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();

8
assets/styles/editor-layout.css

@ -109,6 +109,12 @@ main[data-controller="editor--layout"] {
display: flex; display: flex;
min-height: 0; min-height: 0;
margin: 0; margin: 0;
max-width: 100%;
}
.cm-editor {
height: 100%;
max-width: 100%;
} }
.editor-pane { .editor-pane {
@ -221,7 +227,7 @@ main[data-controller="editor--layout"] {
padding: 0; padding: 0;
margin: 0 !important; margin: 0 !important;
overflow-y: auto; overflow-y: auto;
background-color: var(--color-bg-light); background-color: var(--color-bg);
} }
.preview-container { .preview-container {

78
importmap.php

@ -164,4 +164,82 @@ return [
'version' => '1.30.0', 'version' => '1.30.0',
'type' => 'css', 'type' => 'css',
], ],
'codemirror' => [
'version' => '6.0.2',
],
'@codemirror/lang-markdown' => [
'version' => '6.5.0',
],
'@codemirror/theme-one-dark' => [
'version' => '6.1.3',
],
'@codemirror/view' => [
'version' => '6.37.2',
],
'@codemirror/state' => [
'version' => '6.5.2',
],
'@codemirror/language' => [
'version' => '6.11.1',
],
'@codemirror/commands' => [
'version' => '6.8.1',
],
'@codemirror/search' => [
'version' => '6.5.11',
],
'@codemirror/autocomplete' => [
'version' => '6.18.6',
],
'@codemirror/lint' => [
'version' => '6.8.5',
],
'@lezer/markdown' => [
'version' => '1.5.1',
],
'@codemirror/lang-html' => [
'version' => '6.4.11',
],
'@lezer/common' => [
'version' => '1.2.3',
],
'@lezer/highlight' => [
'version' => '1.2.1',
],
'style-mod' => [
'version' => '4.1.2',
],
'w3c-keyname' => [
'version' => '2.2.8',
],
'crelt' => [
'version' => '1.0.6',
],
'@marijn/find-cluster-break' => [
'version' => '1.0.2',
],
'@lezer/html' => [
'version' => '1.3.12',
],
'@codemirror/lang-css' => [
'version' => '6.3.1',
],
'@codemirror/lang-javascript' => [
'version' => '6.2.4',
],
'@lezer/lr' => [
'version' => '1.4.2',
],
'@lezer/css' => [
'version' => '1.1.9',
],
'@lezer/javascript' => [
'version' => '1.5.1',
],
'@codemirror/lang-json' => [
'version' => '6.0.2',
],
'@lezer/json' => [
'version' => '1.0.3',
],
]; ];

7
templates/editor/layout.html.twig

@ -8,7 +8,6 @@
<div id="editor"> <div id="editor">
{{ value|raw }} {{ value|raw }}
</div> </div>
<input type="hidden" name="editor[content]" data-publishing--quill-target="markdown" data-editor--markdown-sync-target="hidden">
<input type="hidden" {{ block('widget_attributes') }} value="{{ value }}" /> <input type="hidden" {{ block('widget_attributes') }} value="{{ value }}" />
</div> </div>
{% endblock %} {% endblock %}
@ -210,7 +209,7 @@
data-editor--layout-target="markdownPane" data-editor--layout-target="markdownPane"
data-controller="editor--markdown-sync" data-controller="editor--markdown-sync"
> >
{# Markdown editor - source of truth #} {# Markdown editor #}
<div class="markdown-editor-wrapper"> <div class="markdown-editor-wrapper">
<div class="editor-title-input"> <div class="editor-title-input">
<input <input
@ -221,8 +220,8 @@
readonly readonly
/> />
</div> </div>
{{ form_row(form.content, {'label': false, 'attr': {'class': 'form-control editor-md-field', 'data-editor--markdown-sync-target': 'hidden'}}) }} {{ form_row(form.content, {'label': false, 'attr': {'class': 'form-control editor-md-field'}}) }}
<pre class="mt-2" data-editor--markdown-sync-target="code" style="display:none"></pre> <pre class="mt-2" style="display:none"></pre>
{# JS should sync changes in content to content_html (Quill) #} {# JS should sync changes in content to content_html (Quill) #}
</div> </div>
</div> </div>

Loading…
Cancel
Save