900 lines
43 KiB
JavaScript
900 lines
43 KiB
JavaScript
/* /assets/js/bridge/rte-editor.js */
|
|
(function () {
|
|
const PluginName = 'bridge-rte-editor';
|
|
const editorInstances = new WeakMap();
|
|
|
|
class BridgeRTE {
|
|
constructor(bridgeParts) {
|
|
this.B = bridgeParts || (window.BridgeParts || (window.BridgeParts = {}));
|
|
this.editor = null;
|
|
this.modalOpen = false;
|
|
this.allowClose = false;
|
|
this.lastContent = new WeakMap();
|
|
this.restoring = new WeakSet();
|
|
}
|
|
|
|
static serializeHtml(editor) {
|
|
if (!editor || typeof editor.getHtml !== 'function') return '';
|
|
let html = editor.getHtml() || '';
|
|
if (!html) {
|
|
try {
|
|
const wrapper = editor.getWrapper && editor.getWrapper();
|
|
if (wrapper && typeof wrapper.toHTML === 'function') {
|
|
html = wrapper.toHTML();
|
|
}
|
|
} catch {}
|
|
}
|
|
if (!html) {
|
|
try {
|
|
const wrapper = editor.getWrapper && editor.getWrapper();
|
|
const el = wrapper && wrapper.view && wrapper.view.el ? wrapper.view.el : null;
|
|
if (el && el.innerHTML) {
|
|
html = String(el.innerHTML || '');
|
|
}
|
|
} catch {}
|
|
}
|
|
if (!html) {
|
|
try {
|
|
const body = editor.Canvas && editor.Canvas.getBody ? editor.Canvas.getBody() : null;
|
|
if (body && body.innerHTML) {
|
|
html = String(body.innerHTML || '');
|
|
}
|
|
} catch {}
|
|
}
|
|
try {
|
|
const wrapper = editor.getWrapper && editor.getWrapper();
|
|
if (wrapper && wrapper.find) {
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
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'));
|
|
if (!content || !id) return;
|
|
const el = doc.getElementById(id);
|
|
if (el) {
|
|
el.innerHTML = content;
|
|
}
|
|
});
|
|
if (doc.body && doc.body.innerHTML !== undefined) {
|
|
html = doc.body.innerHTML;
|
|
}
|
|
}
|
|
} catch {}
|
|
if (html) {
|
|
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
if (bodyMatch) html = bodyMatch[1];
|
|
}
|
|
return html;
|
|
}
|
|
|
|
log(type, message, color = '#94a3b8', logType = 'info', force = false) {
|
|
if (typeof this.B.log === 'function') {
|
|
this.B.log(PluginName, `[${type}] ${message}`, color, logType, force);
|
|
}
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
return String(text || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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', 'class'].includes(name)) {
|
|
el.removeAttribute(attr.name);
|
|
}
|
|
} else if (!['style', 'class'].includes(name)) {
|
|
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 = this.escapeHtml(text);
|
|
}
|
|
return html;
|
|
}
|
|
|
|
isTextLike(component) {
|
|
return !!(component && component.is && (component.is('text') || component.is('button') || component.is('link')));
|
|
}
|
|
|
|
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 {}
|
|
}
|
|
|
|
applyContentToComponent(editor, component, html) {
|
|
if (!component) return;
|
|
const content = String(html || '');
|
|
const isText = (component.is && component.is('text'))
|
|
|| (component.get && component.get('type') === 'text');
|
|
try {
|
|
if (component.set) component.set('content', content);
|
|
} catch {}
|
|
if (isText && component.view && component.view.el) {
|
|
try {
|
|
component.view.el.innerHTML = content;
|
|
} catch {}
|
|
} else 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);
|
|
}
|
|
}
|
|
|
|
collectFrameCss(editor) {
|
|
const cssParts = [];
|
|
try {
|
|
const frameDoc = editor && editor.Canvas && editor.Canvas.getDocument ? editor.Canvas.getDocument() : null;
|
|
if (frameDoc) {
|
|
frameDoc.querySelectorAll('style').forEach((styleEl) => {
|
|
const css = String(styleEl.textContent || '').trim();
|
|
if (css) cssParts.push(css);
|
|
});
|
|
frameDoc.querySelectorAll('link[rel="stylesheet"][href]').forEach((linkEl) => {
|
|
const href = linkEl.getAttribute('href');
|
|
if (!href) return;
|
|
try {
|
|
const abs = new URL(href, frameDoc.baseURI).href;
|
|
cssParts.push(`@import url("${abs}");`);
|
|
} catch {}
|
|
});
|
|
}
|
|
} catch {}
|
|
return cssParts.join('\n');
|
|
}
|
|
|
|
getEditableTag(component) {
|
|
const viewEl = component && component.view ? component.view.el : null;
|
|
const raw = (component && component.get && (component.get('tagName') || component.get('tag')))
|
|
|| (viewEl && viewEl.tagName)
|
|
|| 'div';
|
|
const tag = String(raw || 'div').toLowerCase();
|
|
const allowed = new Set([
|
|
'div', 'p', 'span', 'a', 'button',
|
|
'strong', 'b', 'em', 'i', 'u', 's',
|
|
'sub', 'sup', 'ul', 'ol', 'li',
|
|
]);
|
|
return allowed.has(tag) ? tag : 'div';
|
|
}
|
|
|
|
applyComponentPreviewStyles(component, content) {
|
|
if (!component || !content) return;
|
|
const mergeStyles = () => {
|
|
const out = {};
|
|
const fromGetStyle = (component.getStyle && component.getStyle()) || {};
|
|
const fromGetAttr = (component.get && component.get('style')) || {};
|
|
const add = (obj) => {
|
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
|
|
Object.entries(obj).forEach(([key, val]) => {
|
|
if (val === undefined || val === null || val === '') return;
|
|
out[key] = val;
|
|
});
|
|
};
|
|
add(fromGetStyle);
|
|
add(fromGetAttr);
|
|
return out;
|
|
};
|
|
const styleObj = mergeStyles();
|
|
const applyStyle = (prop, value) => {
|
|
if (!value) return;
|
|
try { content.style[prop] = value; } catch {}
|
|
};
|
|
const pick = (keys) => {
|
|
for (const key of keys) {
|
|
if (styleObj[key]) return styleObj[key];
|
|
}
|
|
return '';
|
|
};
|
|
applyStyle('fontFamily', pick(['font-family', 'fontFamily']));
|
|
applyStyle('fontSize', pick(['font-size', 'fontSize']));
|
|
applyStyle('fontWeight', pick(['font-weight', 'fontWeight']));
|
|
applyStyle('fontStyle', pick(['font-style', 'fontStyle']));
|
|
applyStyle('textDecoration', pick(['text-decoration', 'textDecoration', 'text-decoration-line']));
|
|
applyStyle('color', pick(['color']));
|
|
applyStyle('lineHeight', pick(['line-height', 'lineHeight']));
|
|
applyStyle('letterSpacing', pick(['letter-spacing', 'letterSpacing']));
|
|
applyStyle('textAlign', pick(['text-align', 'textAlign']));
|
|
applyStyle('backgroundColor', pick(['background-color', 'backgroundColor']));
|
|
}
|
|
|
|
applyComputedPreviewStyles(component, content) {
|
|
try {
|
|
const viewEl = component && component.view ? component.view.el : null;
|
|
if (!viewEl || !viewEl.ownerDocument) return;
|
|
const computed = viewEl.ownerDocument.defaultView
|
|
? viewEl.ownerDocument.defaultView.getComputedStyle(viewEl)
|
|
: null;
|
|
if (!computed) return;
|
|
const map = {
|
|
fontFamily: 'font-family',
|
|
fontSize: 'font-size',
|
|
fontWeight: 'font-weight',
|
|
fontStyle: 'font-style',
|
|
textDecoration: 'text-decoration-line',
|
|
color: 'color',
|
|
lineHeight: 'line-height',
|
|
letterSpacing: 'letter-spacing',
|
|
textAlign: 'text-align',
|
|
backgroundColor: 'background-color',
|
|
};
|
|
Object.entries(map).forEach(([prop, cssProp]) => {
|
|
const val = computed.getPropertyValue(cssProp);
|
|
if (val && val.trim()) {
|
|
try { content.style[prop] = val.trim(); } catch {}
|
|
}
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
openRichTextModal(editor, component) {
|
|
if (!this.isTextLike(component)) {
|
|
this.log('RTE', 'Bitte zuerst ein Text-Element auswaehlen.', '#888');
|
|
return;
|
|
}
|
|
|
|
const modal = editor && editor.Modal;
|
|
if (!modal || this.modalOpen) return;
|
|
this.modalOpen = true;
|
|
this.allowClose = false;
|
|
|
|
let rteInstance = null;
|
|
let rteTargetEl = null;
|
|
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) {
|
|
rteInstance = editor.RichTextEditor;
|
|
rteTargetEl = component.view.el;
|
|
editor.RichTextEditor.disable(component.view.el);
|
|
}
|
|
} catch {}
|
|
|
|
const closeModal = () => {
|
|
this.allowClose = true;
|
|
this.modalOpen = false;
|
|
if (rteInstance && typeof rteInstance.enable === 'function' && rteTargetEl) {
|
|
try {
|
|
const res = rteInstance.enable(rteTargetEl);
|
|
if (res && typeof res.catch === 'function') {
|
|
res.catch(() => {});
|
|
}
|
|
} catch {}
|
|
}
|
|
if (this.B.allowModalCloseOnce) this.B.allowModalCloseOnce();
|
|
if (typeof modal.close === 'function') {
|
|
modal.close();
|
|
} else if (modal.getModel && modal.getModel().set) {
|
|
modal.getModel().set('open', false);
|
|
}
|
|
this.allowClose = 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';
|
|
container.className = 'bridge-rte-container';
|
|
container.setAttribute('data-bridge-rte', 'container');
|
|
|
|
const toolbar = doc.createElement('div');
|
|
toolbar.style.display = 'flex';
|
|
toolbar.style.flexWrap = 'wrap';
|
|
toolbar.style.gap = '6px';
|
|
toolbar.style.alignItems = 'center';
|
|
toolbar.className = 'bridge-rte-toolbar';
|
|
toolbar.setAttribute('data-bridge-rte', 'toolbar');
|
|
|
|
const content = doc.createElement(this.getEditableTag(component));
|
|
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';
|
|
content.className = [content.className, 'bridge-rte-content'].filter(Boolean).join(' ');
|
|
content.setAttribute('data-bridge-rte', 'content');
|
|
if (component && component.view && component.view.el) {
|
|
const viewEl = component.view.el;
|
|
if (viewEl.className) {
|
|
content.className = [content.className, viewEl.className].filter(Boolean).join(' ');
|
|
}
|
|
if (viewEl.id) content.id = viewEl.id;
|
|
}
|
|
|
|
const modelContent = (component.get && component.get('content')) || '';
|
|
const viewHtml = (component.view && component.view.el && component.view.el.innerHTML) || '';
|
|
let htmlSource = viewHtml || modelContent || '';
|
|
if (!htmlSource && component && typeof component.toHTML === 'function') {
|
|
try {
|
|
const fullHtml = String(component.toHTML() || '');
|
|
if (fullHtml) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = fullHtml;
|
|
const first = wrapper.firstElementChild;
|
|
if (first && first.innerHTML !== undefined) {
|
|
htmlSource = first.innerHTML;
|
|
} else {
|
|
htmlSource = wrapper.innerHTML;
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
const initialHtml = this.sanitizeInlineHtml(htmlSource, modelContent || '');
|
|
content.innerHTML = initialHtml;
|
|
const existingStyle = component && component.get && component.get('style') ? component.get('style') : null;
|
|
if (existingStyle && typeof existingStyle === 'object') {
|
|
if (existingStyle.fontFamily) {
|
|
content.style.fontFamily = existingStyle.fontFamily;
|
|
}
|
|
if (existingStyle.fontSize) {
|
|
content.style.fontSize = existingStyle.fontSize;
|
|
}
|
|
}
|
|
this.applyComponentPreviewStyles(component, content);
|
|
this.applyComputedPreviewStyles(component, content);
|
|
|
|
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 getSelectionRange = () => {
|
|
try {
|
|
const docRef = content.ownerDocument || document;
|
|
const sel = docRef.getSelection();
|
|
if (!sel || sel.rangeCount === 0) return null;
|
|
const range = sel.getRangeAt(0);
|
|
if (!content.contains(range.commonAncestorContainer)) return null;
|
|
return range;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const applyInlineStyle = (styleProp, value) => {
|
|
try {
|
|
content.focus();
|
|
restoreSelection();
|
|
const docRef = content.ownerDocument || document;
|
|
const range = getSelectionRange();
|
|
if (!range || range.collapsed) return;
|
|
const wrapper = docRef.createElement('span');
|
|
wrapper.style[styleProp] = value;
|
|
const fragment = range.extractContents();
|
|
wrapper.appendChild(fragment);
|
|
range.insertNode(wrapper);
|
|
const sel = docRef.getSelection();
|
|
if (sel) {
|
|
sel.removeAllRanges();
|
|
const newRange = docRef.createRange();
|
|
newRange.selectNodeContents(wrapper);
|
|
sel.addRange(newRange);
|
|
}
|
|
saveSelection();
|
|
} catch {}
|
|
};
|
|
|
|
const addButton = (labelHtml, title, cmd, valueGetter, handler) => {
|
|
const btn = doc.createElement('button');
|
|
btn.type = 'button';
|
|
btn.innerHTML = labelHtml;
|
|
btn.title = title;
|
|
btn.setAttribute('aria-label', title);
|
|
btn.className = 'bridge-rte-btn';
|
|
btn.setAttribute('data-bridge-rte', 'button');
|
|
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', () => {
|
|
if (typeof handler === 'function') {
|
|
handler();
|
|
return;
|
|
}
|
|
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.className = 'bridge-rte-select';
|
|
select.setAttribute('data-bridge-rte', 'select');
|
|
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 applyComponentStyle = (styleObj, opts = {}) => {
|
|
try {
|
|
if (opts.preview && content) {
|
|
Object.entries(styleObj).forEach(([key, val]) => {
|
|
content.style[key] = val;
|
|
});
|
|
return;
|
|
}
|
|
const normalizeKeys = (obj) => {
|
|
const out = {};
|
|
Object.entries(obj || {}).forEach(([key, val]) => {
|
|
if (key === 'fontFamily') out['font-family'] = val;
|
|
else if (key === 'fontSize') out['font-size'] = val;
|
|
else out[key] = val;
|
|
});
|
|
return out;
|
|
};
|
|
if (component && component.setStyle && component.getStyle) {
|
|
const current = component.getStyle() || {};
|
|
const safeCurrent = (current && typeof current === 'object' && !Array.isArray(current)) ? current : {};
|
|
component.setStyle({ ...safeCurrent, ...normalizeKeys(styleObj) });
|
|
} else if (component && component.set) {
|
|
const current = component.get && component.get('style') ? component.get('style') : {};
|
|
const safeCurrent = (current && typeof current === 'object' && !Array.isArray(current)) ? current : {};
|
|
component.set('style', { ...safeCurrent, ...normalizeKeys(styleObj) });
|
|
}
|
|
if (component && component.view && component.view.el) {
|
|
Object.entries(styleObj).forEach(([key, val]) => {
|
|
component.view.el.style[key] = val;
|
|
});
|
|
}
|
|
} catch {}
|
|
};
|
|
|
|
const pendingComponentStyle = {};
|
|
const ui = this.B.RTE_UI || {};
|
|
const ctx = {
|
|
editor,
|
|
component,
|
|
modal,
|
|
toolbar,
|
|
content,
|
|
addButton,
|
|
addSelect,
|
|
exec,
|
|
applyInlineStyle,
|
|
getSelectionRange,
|
|
applyComponentStyle,
|
|
pendingComponentStyle,
|
|
saveSelection,
|
|
restoreSelection,
|
|
};
|
|
if (typeof ui.beforeOpen === 'function') {
|
|
try { ui.beforeOpen(ctx); } catch {}
|
|
}
|
|
if (typeof ui.buildToolbar === 'function') {
|
|
try { ui.buildToolbar(ctx); } catch {}
|
|
}
|
|
if (!ui.overrideToolbar) {
|
|
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',
|
|
null,
|
|
() => applyInlineStyle('fontWeight', '700')
|
|
);
|
|
addButton(
|
|
icon('M10 4h8v2h-3l-4 12h3v2H6v-2h3l4-12h-3V4z'),
|
|
'Kursiv',
|
|
'italic',
|
|
null,
|
|
() => applyInlineStyle('fontStyle', 'italic')
|
|
);
|
|
addButton(
|
|
icon('M5 4h14v2h-6v3h4a4 4 0 0 1 0 8H7v-2h10a2 2 0 0 0 0-4h-4V6H5V4z'),
|
|
'Unterstrichen',
|
|
'underline',
|
|
null,
|
|
() => applyInlineStyle('textDecoration', '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 = (this.B.RTE_FONTS && Array.isArray(this.B.RTE_FONTS) && this.B.RTE_FONTS.length)
|
|
? this.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 fontSelect = addSelect([{ label: 'Schriftart', value: '' }, ...fontOptions], 'Schriftart', (value) => {
|
|
if (!value) return;
|
|
pendingComponentStyle.fontFamily = value;
|
|
applyComponentStyle({ fontFamily: value }, { preview: true });
|
|
});
|
|
if (existingStyle && existingStyle.fontFamily && fontSelect) {
|
|
fontSelect.value = existingStyle.fontFamily;
|
|
}
|
|
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;
|
|
const range = getSelectionRange();
|
|
if (range && !range.collapsed) {
|
|
applyInlineStyle('fontSize', `${num}px`);
|
|
} else {
|
|
pendingComponentStyle.fontSize = `${num}px`;
|
|
applyComponentStyle({ fontSize: `${num}px` }, { preview: true });
|
|
}
|
|
return;
|
|
}
|
|
const range = getSelectionRange();
|
|
if (range && !range.collapsed) {
|
|
applyInlineStyle('fontSize', `${value}px`);
|
|
return;
|
|
}
|
|
pendingComponentStyle.fontSize = `${value}px`;
|
|
applyComponentStyle({ fontSize: `${value}px` }, { preview: true });
|
|
});
|
|
}
|
|
|
|
const injectedStyle = doc.createElement('style');
|
|
injectedStyle.setAttribute('data-bridge-rte-style', '1');
|
|
const fontCss = (this.B && typeof this.B.RTE_FONT_FACE_CSS === 'string' && this.B.RTE_FONT_FACE_CSS.trim())
|
|
? this.B.RTE_FONT_FACE_CSS.trim()
|
|
: '';
|
|
let editorCss = '';
|
|
try {
|
|
editorCss = editor && typeof editor.getCss === 'function' ? String(editor.getCss() || '') : '';
|
|
} catch {}
|
|
const frameCss = this.collectFrameCss(editor);
|
|
injectedStyle.textContent = `${fontCss}\n${editorCss}\n${frameCss}`.trim();
|
|
if (injectedStyle.textContent) {
|
|
container.appendChild(injectedStyle);
|
|
}
|
|
|
|
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';
|
|
actions.className = 'bridge-rte-actions';
|
|
actions.setAttribute('data-bridge-rte', 'actions');
|
|
|
|
const cancelBtn = doc.createElement('button');
|
|
cancelBtn.type = 'button';
|
|
cancelBtn.textContent = 'Abbrechen';
|
|
cancelBtn.className = 'bridge-rte-btn bridge-rte-btn-cancel';
|
|
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.className = 'bridge-rte-btn bridge-rte-btn-save';
|
|
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 = this.sanitizeInlineHtml(rawHtml, content.textContent || '');
|
|
this.lastContent.set(component, html);
|
|
this.logConsoleSnapshot(editor, component, 'before-save');
|
|
const forceApply = () => {
|
|
if (Object.keys(pendingComponentStyle).length) {
|
|
applyComponentStyle(pendingComponentStyle);
|
|
}
|
|
this.applyContentToComponent(editor, component, html);
|
|
this.logConsoleSnapshot(editor, component, 'after-save');
|
|
};
|
|
forceApply();
|
|
setTimeout(forceApply, 0);
|
|
setTimeout(forceApply, 50);
|
|
if (this.B && this.B.DEBUG_RTE) {
|
|
try {
|
|
const gjsHtml = editor && typeof editor.getHtml === 'function' ? editor.getHtml() : '';
|
|
const serHtml = BridgeRTE.serializeHtml(editor);
|
|
console.group('[RTE DEBUG] save snapshot');
|
|
console.log('component id', component && (component.getId ? component.getId() : component.get && component.get('id')));
|
|
console.log('model content', component && component.get ? component.get('content') : '');
|
|
console.log('view html', component?.view?.el?.innerHTML || '');
|
|
console.log('editor.getHtml len', String(gjsHtml || '').length);
|
|
console.log('serializeHtml len', String(serHtml || '').length);
|
|
console.log('editor.getHtml', gjsHtml);
|
|
console.log('serializeHtml', serHtml);
|
|
console.groupEnd();
|
|
const modelContent = String(component && component.get ? component.get('content') : '');
|
|
const viewHtml = String(component?.view?.el?.innerHTML || '');
|
|
const visibleText = String(component?.view?.el?.textContent || '');
|
|
const summary = {
|
|
source: 'rte',
|
|
modelId: component && (component.getId ? component.getId() : component.get && component.get('id')),
|
|
expectedFromRte: String(html || '').slice(0, 500),
|
|
actualModelContent: modelContent.slice(0, 500),
|
|
visibleText: visibleText.slice(0, 500),
|
|
viewHtmlLen: viewHtml.length,
|
|
modelContentLen: modelContent.length,
|
|
visibleTextLen: visibleText.length,
|
|
editorHtmlLen: String(gjsHtml || '').length,
|
|
serializeHtmlLen: String(serHtml || '').length,
|
|
};
|
|
console.warn('[EDIT SUMMARY]', JSON.stringify(summary));
|
|
} catch {}
|
|
}
|
|
closeModal();
|
|
// RTE-Reaktivierung kann den DOM-Stand überschreiben; danach nochmal anwenden.
|
|
setTimeout(() => this.applyContentToComponent(editor, component, html), 0);
|
|
setTimeout(() => this.applyContentToComponent(editor, component, html), 50);
|
|
});
|
|
|
|
if (typeof ui.buildFooter === 'function') {
|
|
try { ui.buildFooter({ ...ctx, actions, cancelBtn, saveBtn }); } catch {}
|
|
}
|
|
if (!ui.overrideFooter) {
|
|
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) {
|
|
modal.el.classList.add('bridge-rte-modal');
|
|
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();
|
|
});
|
|
}
|
|
}
|
|
const styleEl = document.getElementById('bridge-rte-modal-style') || document.createElement('style');
|
|
styleEl.id = 'bridge-rte-modal-style';
|
|
styleEl.textContent = '.bridge-rte-modal .gjs-mdl-btn-close{display:none!important;}';
|
|
if (!styleEl.parentNode) {
|
|
document.head.appendChild(styleEl);
|
|
}
|
|
if (typeof ui.afterOpen === 'function') {
|
|
try { ui.afterOpen(ctx); } catch {}
|
|
}
|
|
modal.open();
|
|
}
|
|
|
|
ensureTextToolbarButton(component) {
|
|
if (!this.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);
|
|
}
|
|
|
|
setupEditor(editor) {
|
|
if (!editor) return;
|
|
this.editor = editor;
|
|
|
|
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();
|
|
this.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 || this.restoring.has(model)) return;
|
|
const last = this.lastContent.get(model);
|
|
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>') {
|
|
this.restoring.add(model);
|
|
this.logConsoleSnapshot(editor, model, 'restore-start');
|
|
this.applyContentToComponent(editor, model, last);
|
|
this.logConsoleSnapshot(editor, model, 'restore-end');
|
|
this.restoring.delete(model);
|
|
}
|
|
};
|
|
|
|
editor.on('component:update', (model) => restoreIfCollapsed(model));
|
|
editor.on('component:input', (model) => restoreIfCollapsed(model));
|
|
editor.on('component:selected', (model) => this.ensureTextToolbarButton(model));
|
|
editor.on('component:add', (model) => this.ensureTextToolbarButton(model));
|
|
editor.on('component:dblclick', (model) => {
|
|
if (this.isTextLike(model)) this.openRichTextModal(editor, model);
|
|
});
|
|
}
|
|
|
|
mount(editor) {
|
|
this.setupEditor(editor);
|
|
}
|
|
}
|
|
|
|
const setupRichTextEditor = (editor) => {
|
|
if (!editor) return;
|
|
let instance = editorInstances.get(editor);
|
|
if (!instance) {
|
|
instance = new BridgeRTE(window.BridgeParts || (window.BridgeParts = {}));
|
|
editorInstances.set(editor, instance);
|
|
}
|
|
instance.mount(editor);
|
|
};
|
|
|
|
const B = window.BridgeParts || (window.BridgeParts = {});
|
|
B.BridgeRTE = BridgeRTE;
|
|
B.setupRichTextEditor = setupRichTextEditor;
|
|
})();
|