/* /assets/js/bridge/rte-editor.js */
(function () {
const PluginName = 'bridge-rte-editor';
const B = window.BridgeParts || (window.BridgeParts = {});
const log = (type, message, color = '#94a3b8', logType = 'info', force = false) => {
if (typeof B.log === 'function') {
B.log(PluginName, `[${type}] ${message}`, color, logType, force);
}
};
const escapeHtml = (text) => String(text || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
const sanitizeInlineHtml = (rawHtml, fallbackText) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = String(rawHtml || '');
wrapper.querySelectorAll('script,style').forEach((node) => node.remove());
const inlineTags = new Set(['A', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'BR', 'SUB', 'SUP', 'SPAN']);
const blockTags = new Set([
'DIV', 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'UL', 'OL', 'LI', 'TABLE', 'TBODY', 'THEAD', 'TFOOT', 'TR', 'TD', 'TH',
'SECTION', 'ARTICLE', 'ASIDE', 'HEADER', 'FOOTER',
]);
const unwrap = (el, addBreak) => {
const frag = document.createDocumentFragment();
if (addBreak) {
const prev = el.previousSibling;
const hasPrevContent = !!(prev && (prev.nodeType === 3 ? prev.textContent.trim() : (prev.tagName && prev.tagName !== 'BR')));
if (hasPrevContent) {
frag.appendChild(document.createElement('br'));
}
}
while (el.firstChild) {
frag.appendChild(el.firstChild);
}
el.replaceWith(frag);
};
Array.from(wrapper.querySelectorAll('*')).forEach((el) => {
const tag = el.tagName;
if (inlineTags.has(tag)) {
Array.from(el.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
if (tag === 'a') {
if (!['href', 'target', 'rel', 'style'].includes(name)) {
el.removeAttribute(attr.name);
}
} else if (name !== 'style') {
el.removeAttribute(attr.name);
}
});
return;
}
unwrap(el, blockTags.has(tag));
});
let html = wrapper.innerHTML
.replace(/
/gi, '
')
.trim();
const brCount = (html.match(/
/g) || []).length;
if (brCount <= 1) {
html = html.replace(/(
)+$/g, '').trim();
}
if (!html) {
const text = String(fallbackText || wrapper.textContent || '').trim();
if (text) html = escapeHtml(text);
}
return html;
};
const isTextLike = (component) => !!(component && component.is && (component.is('text') || component.is('button') || component.is('link')));
const logConsoleSnapshot = (editor, component, label) => {
try {
const viewEl = component && component.view ? component.view.el : null;
const modelContent = component && component.get ? String(component.get('content') || '') : '';
const editorHtml = editor && typeof editor.getHtml === 'function' ? String(editor.getHtml() || '') : '';
const maxLen = 1000;
console.log(`[RTE DEBUG] ${label}`, {
modelId: component && (component.getId ? component.getId() : component.get && component.get('id')),
modelType: component && component.get ? component.get('type') : undefined,
modelContentLen: modelContent.length,
modelContent: modelContent.slice(0, maxLen),
viewHtmlLen: viewEl ? String(viewEl.innerHTML || '').length : 0,
viewHtml: viewEl ? String(viewEl.innerHTML || '').slice(0, maxLen) : '',
viewOuterLen: viewEl ? String(viewEl.outerHTML || '').length : 0,
viewOuter: viewEl ? String(viewEl.outerHTML || '').slice(0, maxLen) : '',
editorHtmlLen: editorHtml.length,
editorHtml: editorHtml.slice(0, maxLen),
});
} catch {}
};
const applyContentToComponent = (editor, component, html) => {
if (!component) return;
const content = String(html || '');
try {
const isText = (component.is && component.is('text'))
|| (component.get && component.get('type') === 'text');
if (isText && component.components) {
const comps = component.components();
if (comps && typeof comps.reset === 'function') {
comps.reset();
}
}
if (component.set) component.set('content', content);
} catch {}
if (component.view && component.view.el) {
component.view.el.innerHTML = content;
}
if (component.view && typeof component.view.render === 'function') {
component.view.render();
}
if (component.trigger) {
component.trigger('change:content');
component.trigger('change:components');
}
if (editor && typeof editor.trigger === 'function') {
editor.trigger('component:update', component);
}
};
const openRichTextModal = (editor, component) => {
if (!isTextLike(component)) {
log('RTE', 'Bitte zuerst ein Text-Element auswaehlen.', '#888');
return;
}
const modal = editor && editor.Modal;
if (!modal || editor.__bridgeRteModalOpen) return;
editor.__bridgeRteModalOpen = true;
editor.__bridgeRteAllowClose = false;
if (!modal.__bridgeCloseGuarded) {
modal.__bridgeCloseGuarded = true;
modal.__bridgeOriginalClose = modal.close ? modal.close.bind(modal) : null;
if (modal.close) {
modal.close = function (...args) {
if (editor.__bridgeRteAllowClose && modal.__bridgeOriginalClose) {
return modal.__bridgeOriginalClose(...args);
}
return undefined;
};
}
}
try {
const editing = editor.getEditing && editor.getEditing();
if (editing && editing.model === component && editor.setEditing) {
editor.setEditing(null);
}
if (editor.RichTextEditor && editor.RichTextEditor.disable && component.view && component.view.el) {
editor.RichTextEditor.disable(component.view.el);
}
} catch {}
const closeModal = () => {
editor.__bridgeRteAllowClose = true;
editor.__bridgeRteModalOpen = false;
if (typeof modal.__bridgeOriginalClose === 'function') {
modal.__bridgeOriginalClose();
} else if (typeof modal.close === 'function') {
modal.close();
} else if (modal.getModel && modal.getModel().set) {
modal.getModel().set('open', false);
}
editor.__bridgeRteAllowClose = false;
};
const doc = document;
const container = doc.createElement('div');
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '10px';
container.style.height = '100%';
container.style.minHeight = '360px';
const toolbar = doc.createElement('div');
toolbar.style.display = 'flex';
toolbar.style.flexWrap = 'wrap';
toolbar.style.gap = '6px';
toolbar.style.alignItems = 'center';
const content = doc.createElement('div');
content.contentEditable = 'true';
content.style.flex = '1';
content.style.minHeight = '280px';
content.style.border = '1px solid #cbd5f5';
content.style.borderRadius = '6px';
content.style.padding = '12px';
content.style.background = '#ffffff';
content.style.overflow = 'auto';
content.style.fontFamily = 'Arial, sans-serif';
content.style.fontSize = '14px';
const initialHtml = (component.get && component.get('content'))
|| (component.view && component.view.el && component.view.el.innerHTML)
|| '';
content.innerHTML = initialHtml;
let savedRange = null;
const saveSelection = () => {
try {
const sel = (content.ownerDocument || document).getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
if (content.contains(range.commonAncestorContainer)) {
savedRange = range.cloneRange();
}
} catch {}
};
const restoreSelection = () => {
try {
const sel = (content.ownerDocument || document).getSelection();
if (!sel || !savedRange) return;
sel.removeAllRanges();
sel.addRange(savedRange);
} catch {}
};
const exec = (cmd, value) => {
try {
content.focus();
restoreSelection();
const docRef = content.ownerDocument || document;
docRef.execCommand(cmd, false, value);
saveSelection();
} catch {}
};
const applyInlineStyle = (styleProp, value) => {
try {
content.focus();
restoreSelection();
const docRef = content.ownerDocument || document;
const sel = docRef.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
if (range.collapsed) return;
const wrapper = docRef.createElement('span');
wrapper.style[styleProp] = value;
const fragment = range.extractContents();
wrapper.appendChild(fragment);
range.insertNode(wrapper);
sel.removeAllRanges();
const newRange = docRef.createRange();
newRange.selectNodeContents(wrapper);
sel.addRange(newRange);
saveSelection();
} catch {}
};
const addButton = (labelHtml, title, cmd, valueGetter) => {
const btn = doc.createElement('button');
btn.type = 'button';
btn.innerHTML = labelHtml;
btn.title = title;
btn.setAttribute('aria-label', title);
btn.style.padding = '4px 8px';
btn.style.border = '1px solid #cbd5f5';
btn.style.borderRadius = '4px';
btn.style.background = '#f8fafc';
btn.style.cursor = 'pointer';
btn.addEventListener('mousedown', (evt) => {
evt.preventDefault();
saveSelection();
});
btn.addEventListener('click', () => {
const value = typeof valueGetter === 'function' ? valueGetter() : valueGetter;
if (value === null || value === undefined) return;
if (cmd === 'createLink' && !value) return;
exec(cmd, value);
});
toolbar.appendChild(btn);
};
const addSelect = (options, title, onChange) => {
const select = doc.createElement('select');
select.title = title;
select.setAttribute('aria-label', title);
select.style.padding = '4px 8px';
select.style.border = '1px solid #cbd5f5';
select.style.borderRadius = '4px';
select.style.background = '#ffffff';
options.forEach((opt) => {
const optEl = doc.createElement('option');
optEl.value = opt.value;
optEl.textContent = opt.label;
select.appendChild(optEl);
});
select.addEventListener('mousedown', () => {
saveSelection();
});
select.addEventListener('change', () => {
const value = select.value;
if (value) onChange(value);
});
toolbar.appendChild(select);
return select;
};
const icon = (path) => ``;
addButton(icon('M6 4h5a3 3 0 0 1 0 6H6V4zm0 8h6a3 3 0 0 1 0 6H6v-6z'), 'Fett', 'bold');
addButton(icon('M10 4h8v2h-3l-4 12h3v2H6v-2h3l4-12h-3V4z'), 'Kursiv', 'italic');
addButton(icon('M5 4h14v2h-6v3h4a4 4 0 0 1 0 8H7v-2h10a2 2 0 0 0 0-4h-4V6H5V4z'), 'Unterstrichen', 'underline');
addButton(icon('M4 7h10v2H4zM4 11h16v2H4zM4 15h10v2H4z'), 'Liste (ungeordnet)', 'insertUnorderedList');
addButton(icon('M4 6h4v2H4V6zm0 4h4v2H4v-2zm0 4h4v2H4v-2zm6-8h10v2H10V6zm0 4h10v2H10v-2zm0 4h10v2H10v-2z'), 'Liste (geordnet)', 'insertOrderedList');
addButton(icon('M4 7h10v2H4zM4 11h16v2H4zM4 15h12v2H4z'), 'Linksbundig', 'justifyLeft');
addButton(icon('M5 7h14v2H5zM4 11h16v2H4zM5 15h14v2H5z'), 'Zentriert', 'justifyCenter');
addButton(icon('M10 7h10v2H10zM4 11h16v2H4zM8 15h12v2H8z'), 'Rechtsbuendig', 'justifyRight');
addButton(icon('M4 7h16v2H4zM4 11h16v2H4zM4 15h16v2H4z'), 'Blocksatz', 'justifyFull');
addButton(icon('M7 7h10l-1.5 1.5-3-3-5.5 5.5v5h5l5.5-5.5-3-3L17 7z'), 'Link einfuegen', 'createLink', () => prompt('Link-URL eingeben', 'https://'));
addButton(icon('M7 7h4v2H7v4H5V9a2 2 0 0 1 2-2zm10 0a2 2 0 0 1 2 2v4h-2V9h-4V7h4zm0 10h-4v-2h4v-4h2v4a2 2 0 0 1-2 2zm-10 0a2 2 0 0 1-2-2v-4h2v4h4v2H7z'), 'Link entfernen', 'unlink');
addButton(icon('M7 6h10v2H7zM9 10h6v2H9zM10 14h4v2h-4z'), 'Tiefgestellt', 'subscript');
addButton(icon('M7 6h10v2H7zM9 10h6v2H9zM8 14h8v2H8z'), 'Hochgestellt', 'superscript');
addButton(icon('M7 4h4v4H7V4zm6 12h4v4h-4v-4zM7 10h10v2H7v-2z'), 'Einzug', 'indent');
addButton(icon('M13 4h4v4h-4V4zM7 16h4v4H7v-4zM7 10h10v2H7v-2z'), 'Ausruecken', 'outdent');
addButton(icon('M5 5h14v2H5zM5 9h10v2H5zM5 13h14v2H5zM5 17h10v2H5z'), 'Formatierung entfernen', 'removeFormat');
const fontOptions = (B.RTE_FONTS && Array.isArray(B.RTE_FONTS) && B.RTE_FONTS.length)
? B.RTE_FONTS
: [
{ label: 'Arial', value: 'Arial, sans-serif' },
{ label: 'Calibri', value: 'Calibri, sans-serif' },
{ label: 'Cambria', value: 'Cambria, serif' },
{ label: 'Georgia', value: 'Georgia, serif' },
{ label: 'Tahoma', value: 'Tahoma, sans-serif' },
{ label: 'Times New Roman', value: 'Times New Roman, serif' },
{ label: 'Trebuchet MS', value: 'Trebuchet MS, sans-serif' },
{ label: 'Verdana', value: 'Verdana, sans-serif' },
];
const applyComponentStyle = (styleObj, opts = {}) => {
try {
if (opts.preview && content) {
Object.entries(styleObj).forEach(([key, val]) => {
content.style[key] = val;
});
return;
}
if (component && component.set) {
const current = component.get && component.get('style') ? { ...component.get('style') } : {};
component.set('style', { ...current, ...styleObj });
}
if (component && component.view && component.view.el) {
Object.entries(styleObj).forEach(([key, val]) => {
component.view.el.style[key] = val;
});
}
} catch {}
};
addSelect([{ label: 'Schriftart', value: '' }, ...fontOptions], 'Schriftart', (value) => {
if (!value) return;
applyComponentStyle({ fontFamily: value }, { preview: true });
});
addSelect([
{ label: 'Groesse', value: '' },
{ label: '10px', value: '10' },
{ label: '12px', value: '12' },
{ label: '14px', value: '14' },
{ label: '16px', value: '16' },
{ label: '18px', value: '18' },
{ label: '24px', value: '24' },
{ label: '32px', value: '32' },
{ label: 'Custom…', value: '__custom__' },
], 'Schriftgroesse', (value) => {
if (value === '__custom__') {
const raw = prompt('Schriftgroesse in px', '14');
const num = Number(raw || 0);
if (!Number.isFinite(num) || num <= 0) return;
applyInlineStyle('fontSize', `${num}px`);
return;
}
applyInlineStyle('fontSize', `${value}px`);
});
// Emoji-Picker entfernt (auf Wunsch) – kann spaeter als echter Picker wiederkommen.
container.appendChild(toolbar);
container.appendChild(content);
content.addEventListener('keyup', saveSelection);
content.addEventListener('mouseup', saveSelection);
content.addEventListener('focus', saveSelection);
const actions = doc.createElement('div');
actions.style.display = 'flex';
actions.style.justifyContent = 'flex-end';
actions.style.gap = '8px';
const cancelBtn = doc.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = 'Abbrechen';
cancelBtn.style.padding = '6px 12px';
cancelBtn.style.border = '1px solid #cbd5f5';
cancelBtn.style.borderRadius = '4px';
cancelBtn.style.background = '#f8fafc';
cancelBtn.style.cursor = 'pointer';
cancelBtn.addEventListener('click', closeModal);
const saveBtn = doc.createElement('button');
saveBtn.type = 'button';
saveBtn.textContent = 'Speichern';
saveBtn.style.padding = '6px 12px';
saveBtn.style.border = '1px solid #0ea5e9';
saveBtn.style.borderRadius = '4px';
saveBtn.style.background = '#0ea5e9';
saveBtn.style.color = '#ffffff';
saveBtn.style.cursor = 'pointer';
saveBtn.addEventListener('click', () => {
const rawHtml = content.innerHTML || '';
const html = sanitizeInlineHtml(rawHtml, content.textContent || '');
component.__bridgeRteLastContent = html;
logConsoleSnapshot(editor, component, 'before-save');
const forceApply = () => {
applyContentToComponent(editor, component, html);
logConsoleSnapshot(editor, component, 'after-save');
};
forceApply();
setTimeout(forceApply, 0);
setTimeout(forceApply, 50);
setTimeout(() => {
try {
if (component.view && component.view.el) {
component.view.el.innerHTML = html;
}
} catch {}
}, 120);
closeModal();
});
actions.appendChild(cancelBtn);
actions.appendChild(saveBtn);
container.appendChild(actions);
modal.setTitle('Richtext Editor');
modal.setContent(container);
const mdl = modal.getModel && modal.getModel();
if (mdl && mdl.set) {
mdl.set('closeOnEsc', false);
mdl.set('closeOnClick', false);
}
if (modal.el) {
const closeBtn = modal.el.querySelector('.gjs-mdl-btn-close');
if (closeBtn) closeBtn.style.display = 'none';
const backdrop = modal.el.querySelector('.gjs-mdl-dialog');
if (backdrop) {
backdrop.addEventListener('click', (evt) => {
evt.stopPropagation();
});
}
}
modal.open();
};
const ensureTextToolbarButton = (component) => {
if (!isTextLike(component) || !component.get) return;
const toolbar = component.get('toolbar') || [];
if (toolbar.some((item) => item && item.command === 'bridge-open-richtext')) return;
toolbar.push({
label: '',
attributes: { title: 'Richtext bearbeiten' },
command: 'bridge-open-richtext',
});
component.set('toolbar', toolbar);
};
const setupRichTextEditor = (editor) => {
if (!editor) return;
if (!editor.__bridgeGetHtmlPatched) {
editor.__bridgeGetHtmlPatched = true;
const originalGetHtml = editor.getHtml ? editor.getHtml.bind(editor) : null;
if (originalGetHtml) {
editor.getHtml = function (...args) {
let html = originalGetHtml(...args);
try {
const wrapper = editor.getWrapper && editor.getWrapper();
if (wrapper && wrapper.find) {
const candidates = wrapper.find('[data-gjs-type="text"], [data-gjs-type="link"], [data-gjs-type="button"]');
candidates.forEach((model) => {
const content = model && model.get ? String(model.get('content') || '') : '';
const id = model && (model.getId ? model.getId() : model.get && model.get('id'));
const tag = (model && model.get && model.get('tagName') ? model.get('tagName') : (model && model.get && model.get('tag'))) || 'div';
if (!content || !id) return;
const tagName = String(tag).toLowerCase();
const rx = new RegExp(`<${tagName}([^>]*\\bid=["']${id}["'][^>]*)>([\\s\\S]*?)<\\/${tagName}>`, 'i');
html = html.replace(rx, `<${tagName}$1>${content}${tagName}>`);
});
}
} catch {}
return html;
};
}
}
if (editor.Commands && editor.Commands.add) {
editor.Commands.add('bridge-open-richtext', {
run(ed, sender, opts = {}) {
if (sender && sender.set) sender.set('active', 0);
const component = opts.component || ed.getSelected();
openRichTextModal(ed, component);
},
});
}
const cfg = editor.getConfig ? editor.getConfig() : {};
cfg.richTextEditor = cfg.richTextEditor || {};
const actions = Array.isArray(cfg.richTextEditor.actions)
? cfg.richTextEditor.actions.slice()
: ['bold', 'italic', 'underline', 'strikethrough', 'link'];
if (!actions.includes('bridge-open-richtext')) actions.push('bridge-open-richtext');
cfg.richTextEditor.actions = actions;
const restoreIfCollapsed = (model) => {
if (!model || model.__bridgeRteRestoring) return;
const last = model.__bridgeRteLastContent;
if (!last || !model.get) return;
const current = String(model.get('content') || '').trim();
if (!current || current === '