diff --git a/public/assets/js/bridge/blocks-api.js b/public/assets/js/bridge/blocks-api.js index 6c66495..1dd1afc 100644 --- a/public/assets/js/bridge/blocks-api.js +++ b/public/assets/js/bridge/blocks-api.js @@ -469,7 +469,13 @@ ? B.RTE_FONT_FACE_CSS.trim() : ''; const cssPayload = (fontCss ? fontCss + '\n' : '') + editor.getCss(); - const htmlContent = editor.getHtml() + ''; + const serializeHtml = () => { + if (B && B.BridgeRTE && typeof B.BridgeRTE.serializeHtml === 'function') { + return B.BridgeRTE.serializeHtml(editor); + } + return editor.getHtml(); + }; + const htmlContent = serializeHtml() + ''; // 2. KRITISCH: Holt die JSON-Repräsentation des Editors let jsonProjectDataRaw = ''; try { diff --git a/public/assets/js/bridge/rte-editor.js b/public/assets/js/bridge/rte-editor.js index d79488f..f34a20f 100644 --- a/public/assets/js/bridge/rte-editor.js +++ b/public/assets/js/bridge/rte-editor.js @@ -1,779 +1,832 @@ /* /assets/js/bridge/rte-editor.js */ (function () { const PluginName = 'bridge-rte-editor'; - const B = window.BridgeParts || (window.BridgeParts = {}); + const editorInstances = new WeakMap(); - const log = (type, message, color = '#94a3b8', logType = 'info', force = false) => { - if (typeof B.log === 'function') { - B.log(PluginName, `[${type}] ${message}`, color, logType, force); + 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(); } - }; - 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')); + static serializeHtml(editor) { + if (!editor || typeof editor.getHtml !== 'function') return ''; + let html = editor.getHtml(); + 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('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}`); + }); } - } - while (el.firstChild) { - frag.appendChild(el.firstChild); - } - el.replaceWith(frag); - }; + } catch {} + return html; + } - 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)) { + 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, '''); + } + + 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); } - } else if (!['style', 'class'].includes(name)) { - 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 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'); - }; - - const 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'; - }; - - const 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'])); - }; - - const 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 {} - }; - - 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 (B.allowModalCloseOnce) B.allowModalCloseOnce(); - 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(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'; - if (component && component.view && component.view.el) { - const viewEl = component.view.el; - if (viewEl.className) content.className = viewEl.className; - 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 = 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; - } - } - applyComponentPreviewStyles(component, content); - 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.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.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', - 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 = (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; } - 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) }); + 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 = 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 || ''); + 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 && component.view && component.view.el) { - Object.entries(styleObj).forEach(([key, val]) => { - component.view.el.style[key] = val; + 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); + } + } + + 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 {} - }; - - const pendingComponentStyle = {}; - 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 }); - }); - - // Emoji-Picker entfernt (auf Wunsch) – kann spaeter als echter Picker wiederkommen. - - const injectedStyle = doc.createElement('style'); - injectedStyle.setAttribute('data-bridge-rte-style', '1'); - const fontCss = (B && typeof B.RTE_FONT_FACE_CSS === 'string' && B.RTE_FONT_FACE_CSS.trim()) - ? B.RTE_FONT_FACE_CSS.trim() - : ''; - let editorCss = ''; - try { - editorCss = editor && typeof editor.getCss === 'function' ? String(editor.getCss() || '') : ''; - } catch {} - const frameCss = collectFrameCss(editor); - injectedStyle.textContent = `${fontCss}\n${editorCss}\n${frameCss}`.trim(); - if (injectedStyle.textContent) { - container.appendChild(injectedStyle); + return cssParts.join('\n'); } - container.appendChild(toolbar); - container.appendChild(content); - content.addEventListener('keyup', saveSelection); - content.addEventListener('mouseup', saveSelection); - content.addEventListener('focus', saveSelection); + 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'; + } - 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 = () => { - if (Object.keys(pendingComponentStyle).length) { - applyComponentStyle(pendingComponentStyle); - } - applyContentToComponent(editor, component, html); - logConsoleSnapshot(editor, component, 'after-save'); + 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; }; - forceApply(); - setTimeout(forceApply, 0); - setTimeout(forceApply, 50); - setTimeout(() => { + 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; + + 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 = () => { + this.allowClose = true; + this.modalOpen = false; + 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 { - if (component.view && component.view.el) { - component.view.el.innerHTML = html; + 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 {} - }, 120); - closeModal(); - }); + } + 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); - 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 (!mdl.__bridgeRteGuarded && typeof mdl.on === 'function') { - mdl.__bridgeRteGuarded = true; - mdl.on('change:open', () => { - if (!mdl.get('open') && !editor.__bridgeRteAllowClose) { - mdl.set('open', true, { silent: true }); + 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(); } - }); - } - } - 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); - } - modal.open(); - }; + } 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 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 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) => ``; + 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); + setTimeout(() => { + try { + if (component.view && component.view.el) { + component.view.el.innerHTML = html; + } + } catch {} + }, 120); + closeModal(); + }); + + 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: '', + 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 === '
' || current === '

' || current === '


') { + 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; - 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}`); - }); - } - } catch {} - return html; - }; - } + let instance = editorInstances.get(editor); + if (!instance) { + instance = new BridgeRTE(window.BridgeParts || (window.BridgeParts = {})); + editorInstances.set(editor, instance); } - 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 === '
' || current === '

' || current === '


') { - 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); - }); + instance.mount(editor); }; + const B = window.BridgeParts || (window.BridgeParts = {}); + B.BridgeRTE = BridgeRTE; B.setupRichTextEditor = setupRichTextEditor; })(); diff --git a/public/assets/js/ui-editor.js b/public/assets/js/ui-editor.js index 6f26e4d..df6b993 100644 --- a/public/assets/js/ui-editor.js +++ b/public/assets/js/ui-editor.js @@ -177,6 +177,15 @@ export function initEditor() { }; } + function getSerializedHtml(editor, win) { + if (!editor) return ''; + const BridgeRTE = win?.BridgeParts?.BridgeRTE || win?.BridgeRTE || null; + if (BridgeRTE && typeof BridgeRTE.serializeHtml === 'function') { + return BridgeRTE.serializeHtml(editor); + } + return (typeof editor.getHtml === 'function') ? editor.getHtml() : ''; + } + async function buildCurrentSnapshot() { if (currentEditorType === 'craftjs') { return buildSnapshot({ @@ -191,7 +200,7 @@ export function initEditor() { const win = iframe?.contentWindow; const fontCss = win?.BridgeParts?.RTE_FONT_FACE_CSS || ''; const cssPayload = (fontCss ? fontCss + '\n' : '') + (editor.getCss() || ''); - const htmlContent = (editor.getHtml() || '') + ''; + const htmlContent = (getSerializedHtml(editor, win) || '') + ''; let jsonRaw = ''; try { jsonRaw = JSON.stringify(editor.getProjectData()); @@ -440,7 +449,7 @@ export function initEditor() { const ed = win.__gjs || (win.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null; if (ed && typeof ed.getHtml === 'function') { - const html = ed.getHtml(); + const html = getSerializedHtml(ed, win); const css = (typeof ed.getCss === 'function') ? ed.getCss() : ''; return css ? `\n${html}` : html; }