diff --git a/assets/controllers/editor/conversion.js b/assets/controllers/editor/conversion.js new file mode 100644 index 0000000..c9b3300 --- /dev/null +++ b/assets/controllers/editor/conversion.js @@ -0,0 +1,402 @@ +import Delta from '../../vendor/quill-delta/quill-delta.index.js'; + +/** + * Optional: enforce canonical delta contract during development. + * Enable by passing { strict: true } to deltaToMarkdown(). + */ +function assertCanonicalDelta(delta) { + if (!delta || !Array.isArray(delta.ops)) throw new Error('Invalid delta: missing ops array'); + + for (const op of delta.ops) { + // Embeds are allowed + if (op.insert && typeof op.insert === 'object') continue; + + if (typeof op.insert === 'string') { + const isNewline = op.insert === '\n'; + + // Canonical rule: text ops must not contain embedded newlines + if (!isNewline && op.insert.includes('\n')) { + throw new Error('Non-canonical delta: text op contains embedded \\n'); + } + + // Canonical rule: block attrs must appear only on newline ops + if (!isNewline && op.attributes) { + const blockKeys = ['header', 'blockquote', 'list', 'indent', 'code-block']; + for (const k of blockKeys) { + if (k in op.attributes) { + throw new Error(`Non-canonical delta: block attr "${k}" found on text op`); + } + } + } + } + } +} + +// --- Delta to Markdown (canonical) --- +export function deltaToMarkdown(delta, opts = {}) { + const options = { + strict: false, // set true during dev to catch non-canonical deltas + fence: '```', + orderedListStyle: 'one', // 'one' or 'increment' + embedToMarkdown: (embed) => { + if (!embed || typeof embed !== 'object') return ''; + if (embed.image) return `})`; + if (embed.video) return String(embed.video); + if (embed.nostr) return String(embed.nostr); + return ''; + }, + ...opts, + }; + + if (!delta || !Array.isArray(delta.ops)) return ''; + if (options.strict) assertCanonicalDelta(delta); + + let md = ''; + let line = ''; + + // Block state + let inCodeBlock = false; + let inList = null; // 'ordered' | 'bullet' | null + let listCounter = 1; + + const escapeText = (s) => + String(s) + .replace(/\\/g, '\\\\') + .replace(/([*_`[\]~])/g, '\\$1'); + + const escapeLinkText = (s) => + String(s).replace(/\\/g, '\\\\').replace(/([\[\]])/g, '\\$1'); + + const escapeLinkUrl = (s) => String(s).replace(/\s/g, '%20'); + + function renderInlineText(text, attrs = {}) { + if (!text) return ''; + + if (attrs.code) { + const t = String(text).replace(/`/g, '\\`'); + return `\`${t}\``; + } + + let out = escapeText(text); + + if (attrs.link) { + out = `[${escapeLinkText(out)}](${escapeLinkUrl(attrs.link)})`; + } + + // wrapper order is a choice; keep it stable + if (attrs.strike) out = `~~${out}~~`; + if (attrs.bold) out = `**${out}**`; + if (attrs.italic) out = `*${out}*`; + + return out; + } + + const closeList = () => { + if (inList) { + md += '\n'; + inList = null; + listCounter = 1; + } + }; + + const openFence = () => { + if (!inCodeBlock) { + closeList(); + md += `${options.fence}\n`; + inCodeBlock = true; + } + }; + + const closeFence = () => { + if (inCodeBlock) { + md += `${options.fence}\n\n`; + inCodeBlock = false; + } + }; + + function flushLine(attrs = {}) { + // code block line + if (attrs['code-block']) { + openFence(); + md += `${line}\n`; // raw + line = ''; + return; + } + + // leaving code block? + closeFence(); + + const indent = Number.isFinite(attrs.indent) ? attrs.indent : 0; + const indentPrefix = indent > 0 ? ' '.repeat(indent) : ''; + + // list line + if (attrs.list === 'ordered' || attrs.list === 'bullet') { + const newType = attrs.list; + if (inList && inList !== newType) md += '\n'; + if (!inList) listCounter = 1; + inList = newType; + + const marker = + newType === 'ordered' + ? (options.orderedListStyle === 'increment' ? `${listCounter++}. ` : '1. ') + : '- '; + + md += `${indentPrefix}${marker}${line}\n`; + line = ''; + return; + } + + // not list anymore + closeList(); + + // blockquote + if (attrs.blockquote) { + md += line.length ? `> ${line}\n` : '>\n'; + line = ''; + return; + } + + // header + if (attrs.header) { + const level = Math.min(6, Math.max(1, Number(attrs.header) || 1)); + md += `${'#'.repeat(level)} ${line}\n`; + line = ''; + return; + } + + // normal / blank + if (!line.length) { + md += '\n'; + return; + } + + md += `${line}\n`; + line = ''; + } + + for (const op of delta.ops) { + // embeds + if (op.insert && typeof op.insert === 'object') { + const embedMd = options.embedToMarkdown(op.insert); + if (embedMd) line += embedMd; + continue; + } + + // newline + if (op.insert === '\n') { + flushLine(op.attributes || {}); + continue; + } + + // text + if (typeof op.insert === 'string') { + // If you truly enforce canonical, this is safe. + // If you want mild robustness without supporting "weird attrs", + // you can split embedded newlines but apply NO block attrs here: + if (op.insert.includes('\n')) { + // Non-canonical: split without block formatting support + const parts = op.insert.split('\n'); + for (let p = 0; p < parts.length; p++) { + if (parts[p]) line += renderInlineText(parts[p], op.attributes || {}); + if (p < parts.length - 1) flushLine({}); + } + } else { + if (inCodeBlock) line += op.insert; // raw inside fence + else line += renderInlineText(op.insert, op.attributes || {}); + } + } + } + + // finalize + if (line.length) { + closeFence(); + closeList(); + md += `${line}\n`; + line = ''; + } + + closeFence(); + closeList(); + + md = md.replace(/\n{4,}/g, '\n\n\n'); + return md.replace(/[ \t]+\n/g, '\n').replace(/\s+$/, ''); +} + +// --- Markdown to Delta (canonical) --- +export function markdownToDelta(md, opts = {}) { + const options = { + fence: '```', + indentSize: 2, // spaces per indent level for lists + ...opts, + }; + + if (!md) return new Delta([{ insert: '\n' }]); + + const lines = md.replace(/\r\n/g, '\n').split('\n'); + const ops = []; + let inCodeBlock = false; + + for (const rawLine of lines) { + const line = rawLine; + + // code fence toggle + if (line.trim().startsWith(options.fence)) { + inCodeBlock = !inCodeBlock; + continue; + } + + // code block content: emit per-line canonical Quill code-block + if (inCodeBlock) { + if (line.length) ops.push({ insert: line }); + ops.push({ insert: '\n', attributes: { 'code-block': true } }); + continue; + } + + // blank line + if (line.trim() === '') { + ops.push({ insert: '\n' }); + continue; + } + + // header + const headerMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headerMatch) { + const level = headerMatch[1].length; + const content = headerMatch[2] ?? ''; + ops.push(...inlineMarkdownToOps(content)); + ops.push({ insert: '\n', attributes: { header: level } }); + continue; + } + + // blockquote (canonical: attrs on newline) + const quoteMatch = line.match(/^>\s?(.*)$/); + if (quoteMatch) { + const content = quoteMatch[1] ?? ''; + ops.push(...inlineMarkdownToOps(content)); + ops.push({ insert: '\n', attributes: { blockquote: true } }); + continue; + } + + // lists with indent + const leadingSpaces = (line.match(/^(\s*)/)?.[1] ?? '').replace(/\t/g, ' ').length; + const indent = Math.floor(leadingSpaces / options.indentSize); + const trimmed = line.trimStart(); + + const olMatch = trimmed.match(/^\d+\.\s+(.*)$/); + if (olMatch) { + const content = olMatch[1] ?? ''; + ops.push(...inlineMarkdownToOps(content)); + ops.push({ insert: '\n', attributes: { list: 'ordered', ...(indent ? { indent } : {}) } }); + continue; + } + + const ulMatch = trimmed.match(/^[-*]\s+(.*)$/); + if (ulMatch) { + const content = ulMatch[1] ?? ''; + ops.push(...inlineMarkdownToOps(content)); + ops.push({ insert: '\n', attributes: { list: 'bullet', ...(indent ? { indent } : {}) } }); + continue; + } + + // paragraph + ops.push(...inlineMarkdownToOps(line)); + ops.push({ insert: '\n' }); + } + + // ensure trailing newline + if (ops.length === 0 || ops[ops.length - 1].insert !== '\n') ops.push({ insert: '\n' }); + + return new Delta(ops); +} + +// Deterministic inline parser for your subset. +// (This replaces regex-overlap issues in parseInlineOps.) +function inlineMarkdownToOps(text) { + const ops = []; + let i = 0; + + const pushText = (t) => { if (t) ops.push({ insert: t }); }; + + while (i < text.length) { + // inline code + if (text[i] === '`') { + const end = text.indexOf('`', i + 1); + if (end !== -1) { + const content = text.slice(i + 1, end); + if (content) ops.push({ insert: content, attributes: { code: true } }); + i = end + 1; + continue; + } + pushText('`'); i++; continue; + } + + // link + if (text[i] === '[') { + const closeBracket = text.indexOf(']', i + 1); + if (closeBracket !== -1 && text[closeBracket + 1] === '(') { + const closeParen = text.indexOf(')', closeBracket + 2); + if (closeParen !== -1) { + const label = text.slice(i + 1, closeBracket); + const url = text.slice(closeBracket + 2, closeParen); + if (label) ops.push({ insert: label, attributes: { link: url } }); + i = closeParen + 1; + continue; + } + } + pushText('['); i++; continue; + } + + // bold + if (text.startsWith('**', i)) { + const end = text.indexOf('**', i + 2); + if (end !== -1) { + const content = text.slice(i + 2, end); + if (content) ops.push({ insert: content, attributes: { bold: true } }); + i = end + 2; + continue; + } + pushText('*'); i++; continue; + } + + // strike + if (text.startsWith('~~', i)) { + const end = text.indexOf('~~', i + 2); + if (end !== -1) { + const content = text.slice(i + 2, end); + if (content) ops.push({ insert: content, attributes: { strike: true } }); + i = end + 2; + continue; + } + pushText('~'); i++; continue; + } + + // italic + if (text[i] === '*') { + const end = text.indexOf('*', i + 1); + if (end !== -1) { + const content = text.slice(i + 1, end); + if (content) ops.push({ insert: content, attributes: { italic: true } }); + i = end + 1; + continue; + } + pushText('*'); i++; continue; + } + + // plain run + const next = nextSpecialIndex(text, i); + pushText(text.slice(i, next)); + i = next; + } + + return ops; +} + +function nextSpecialIndex(text, start) { + const specials = ['`', '[', '*', '~']; + let min = text.length; + for (const ch of specials) { + const idx = text.indexOf(ch, start); + if (idx !== -1 && idx < min) min = idx; + } + return min; +} diff --git a/assets/controllers/editor/layout_controller.js b/assets/controllers/editor/layout_controller.js index 56758b3..229551d 100644 --- a/assets/controllers/editor/layout_controller.js +++ b/assets/controllers/editor/layout_controller.js @@ -1,5 +1,6 @@ // assets/controllers/editor/layout_controller.js import {Controller} from '@hotwired/stimulus'; +import { deltaToMarkdown, markdownToDelta } from './conversion.js'; export default class extends Controller { static targets = [ @@ -14,6 +15,18 @@ export default class extends Controller { 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]"]'); @@ -30,24 +43,37 @@ export default class extends Controller { this.element.addEventListener('content:changed', () => { this.updatePreview(); this.updateJsonCode(); - // 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 || ''; }); - } - } + // 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; @@ -63,30 +89,35 @@ export default class extends Controller { this.previewPaneTarget.classList.toggle('is-hidden', mode !== 'preview'); // Update content when switching modes - if (mode === 'markdown') { - this.updateMarkdown(); + 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') { - // Sync Markdown to Quill when switching to Quill pane + // 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 && 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 || ''; }); - } + 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() { @@ -287,4 +318,57 @@ export default class extends Controller { 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 + })); + } } diff --git a/assets/controllers/editor/markdown-sync_controller.js b/assets/controllers/editor/markdown-sync_controller.js index f242dfb..c8a68ad 100644 --- a/assets/controllers/editor/markdown-sync_controller.js +++ b/assets/controllers/editor/markdown-sync_controller.js @@ -35,6 +35,17 @@ export default class extends Controller { // Observe programmatic changes to the value attribute this.observer = new MutationObserver(() => this.updateMarkdown()); this.observer.observe(this.textarea, { attributes: true, attributeFilter: ["value"] }); + + // --- Call layoutController.onMarkdownChange for state sync --- + this.textarea.addEventListener('input', () => { + const layoutController = this.application.getControllerForElementAndIdentifier( + this.element.closest('[data-controller~="editor--layout"]'), + 'editor--layout' + ); + if (layoutController && typeof layoutController.onMarkdownChange === 'function') { + layoutController.onMarkdownChange(this.textarea.value); + } + }); } disconnect() { @@ -51,27 +62,5 @@ export default class extends Controller { if (this.codePreview) { this.codePreview.textContent = this.textarea.value; } - // Sync Markdown to Quill (content_html) - if (window.appQuill) { - let html = ''; - if (window.marked) { - html = window.marked.parse(this.textarea.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.textarea.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/publishing/quill_controller.js b/assets/controllers/publishing/quill_controller.js index 146874c..c1fd483 100644 --- a/assets/controllers/publishing/quill_controller.js +++ b/assets/controllers/publishing/quill_controller.js @@ -192,6 +192,14 @@ export default class extends Controller { this.element.dispatchEvent(new CustomEvent('content:changed', { bubbles: true })); } } + // --- Call layoutController.onQuillChange for state sync --- + const layoutController = this.application.getControllerForElementAndIdentifier( + this.element.closest('[data-controller~="editor--layout"]'), + 'editor--layout' + ); + if (layoutController && typeof layoutController.onQuillChange === 'function') { + layoutController.onQuillChange(this.quill.getContents()); + } }); // Expose a method to set Quill content from HTML diff --git a/src/Form/EditorType.php b/src/Form/EditorType.php index cb4d3b0..832aadc 100644 --- a/src/Form/EditorType.php +++ b/src/Form/EditorType.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace App\Form; -use App\Dto\AdvancedMetadata; use App\Entity\Article; use App\Form\DataTransformer\CommaSeparatedToJsonTransformer; use App\Form\Type\QuillType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; @@ -68,6 +68,16 @@ class EditorType extends AbstractType 'label' => false, 'required' => false, 'mapped' => false, + ]) + ->add('contentDelta', HiddenType::class, [ + 'required' => false, + 'mapped' => false, + 'attr' => ['type' => 'hidden'], + ]) + ->add('contentNMD', HiddenType::class, [ + 'required' => false, + 'mapped' => false, + 'attr' => ['type' => 'hidden'], ]); // Apply the custom transformer diff --git a/templates/editor/layout.html.twig b/templates/editor/layout.html.twig index c3801ad..a6d5005 100644 --- a/templates/editor/layout.html.twig +++ b/templates/editor/layout.html.twig @@ -93,6 +93,9 @@