Browse Source

Editor: conversion

imwald
Nuša Pukšič 2 weeks ago
parent
commit
a0e05dbcc0
  1. 402
      assets/controllers/editor/conversion.js
  2. 146
      assets/controllers/editor/layout_controller.js
  3. 33
      assets/controllers/editor/markdown-sync_controller.js
  4. 8
      assets/controllers/publishing/quill_controller.js
  5. 12
      src/Form/EditorType.php
  6. 21
      templates/editor/layout.html.twig
  7. 1
      templates/editor/panels/_relays.html.twig

402
assets/controllers/editor/conversion.js

@ -0,0 +1,402 @@ @@ -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 `![](${String(embed.image)})`;
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;
}

146
assets/controllers/editor/layout_controller.js

@ -1,5 +1,6 @@ @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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
}));
}
}

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

@ -35,6 +35,17 @@ export default class extends Controller { @@ -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 { @@ -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;
}
}
}

8
assets/controllers/publishing/quill_controller.js

@ -192,6 +192,14 @@ export default class extends Controller { @@ -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

12
src/Form/EditorType.php

@ -4,12 +4,12 @@ declare(strict_types=1); @@ -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 @@ -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

21
templates/editor/layout.html.twig

@ -93,6 +93,9 @@ @@ -93,6 +93,9 @@
<div class="editor-center-content">
{{ form_start(form) }}
{# Hidden fields for content delta and NMD #}
{{ form_widget(form.contentDelta) }}
{{ form_widget(form.contentNMD) }}
<div
class="editor-pane editor-pane--edit"
@ -203,15 +206,15 @@ @@ -203,15 +206,15 @@
>
Metadata
</button>
<button
type="button"
class="editor-sidebar-tab"
data-editor--panels-target="tab"
data-panel="advanced"
data-action="editor--panels#switch"
>
Advanced
</button>
{# <button#}
{# type="button"#}
{# class="editor-sidebar-tab"#}
{# data-editor--panels-target="tab"#}
{# data-panel="advanced"#}
{# data-action="editor--panels#switch"#}
{# >#}
{# Advanced#}
{# </button>#}
<button
type="button"
class="editor-sidebar-tab"

1
templates/editor/panels/_relays.html.twig

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
{% if app.user %}
<div class="panel-section">
<h3>Relays</h3>
<p>Your article will be published to the following relays.</p>
<small>You'll be able to select a subset in the future.</small>
{% set relays = app.user.relays %}

Loading…
Cancel
Save