Files
emailtemplate.it/public/assets/js/bridge/rte-editor.js
2026-01-17 03:06:42 +01:00

509 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* /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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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(/<br\s*\/?>/gi, '<br>')
.trim();
const brCount = (html.match(/<br>/g) || []).length;
if (brCount <= 1) {
html = html.replace(/(<br>)+$/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;
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.__bridgeRteModalOpen = false;
if (typeof modal.close === 'function') {
modal.close();
} else if (modal.getModel && modal.getModel().set) {
modal.getModel().set('open', 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) => `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="${path}" fill="currentColor"/></svg>`;
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) => {
try {
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 });
});
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', true);
mdl.set('closeOnClick', true);
}
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: '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M4 20h4l10-10-4-4L4 16v4zm14.7-11.3c.4-.4.4-1 0-1.4l-2-2c-.4-.4-1-.4-1.4 0l-1.3 1.3 4 4 1.7-1.9z" fill="currentColor"/></svg>',
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 === '<div></div>' || current === '<div><br></div>' || current === '<div><br></div><br>') {
model.__bridgeRteRestoring = true;
logConsoleSnapshot(editor, model, 'restore-start');
applyContentToComponent(editor, model, last);
logConsoleSnapshot(editor, model, 'restore-end');
model.__bridgeRteRestoring = false;
}
};
editor.on('component:update', (model) => restoreIfCollapsed(model));
editor.on('component:input', (model) => restoreIfCollapsed(model));
editor.on('component:selected', (model) => ensureTextToolbarButton(model));
editor.on('component:add', (model) => ensureTextToolbarButton(model));
editor.on('component:dblclick', (model) => {
if (isTextLike(model)) openRichTextModal(editor, model);
});
};
B.setupRichTextEditor = setupRichTextEditor;
})();