/* /assets/js/bridge/blocks-placeholder.js (LOG-KONTROLLIERT) */
(function () {
const PluginName = 'blocks-placeholder';
const B = window.BridgeParts || (window.BridgeParts = {});
if (B.LOG_CONFIG && B.LOG_CONFIG.PLUGINS) {
B.LOG_CONFIG.PLUGINS[PluginName] = false; // Lokaler Schalter für dieses Plugin
}
const log = (type, message, color = '#FFD700', logType = 'info', force = false) => {
if (typeof B.log === 'function') {
B.log(PluginName, `[${type}] ${message}`, color, logType, force);
} else if (logType === 'error') {
console.error(`%c[${PluginName} - ${type}] %c${message}`, `color:red; font-weight:bold;`, 'color:inherit;');
}
};
log('FILE CHECK', 'Placeholder-Datei-IIFE startet.');
if (window.__PLACEHOLDER_BLOCKS_LOADED) return;
window.__PLACEHOLDER_BLOCKS_LOADED = true;
const TARGET_CAT_ID = 'placeholders';
const PLACEHOLDER_COMPONENT = 'placeholder-block';
const ALL_PLACEHOLDER_BLOCK_IDS = [];
const INLINE_PLACEHOLDER_CLASS = 'bridge-placeholder-inline';
const PLACEHOLDER_MARKER_ATTR = 'data-placeholder-marker';
const placeholderSchemaStore = {
promise: null,
tables: [],
status: null,
statusPromise: null,
};
let inlineStyleInjected = false;
let suppressPlaceholderPrompt = 0;
const ensureInlinePlaceholderStyles = () => {
if (inlineStyleInjected || typeof document === 'undefined') return;
inlineStyleInjected = true;
const style = document.createElement('style');
style.id = 'bridge-placeholder-inline-style';
style.textContent = `
.${INLINE_PLACEHOLDER_CLASS}{
display:inline-flex;
align-items:center;
gap:4px;
padding:2px 8px;
border:1px dashed #94a3b8;
border-radius:6px;
background:#f1f5f9;
font-family:monospace;
font-size:12px;
cursor:pointer;
user-select:none;
}
.${INLINE_PLACEHOLDER_CLASS}:hover{
background:#e2e8f0;
}
`;
document.head && document.head.appendChild(style);
};
const createEl = (tag, props = {}, children = []) => {
const el = document.createElement(tag);
Object.entries(props || {}).forEach(([key, value]) => {
if (value === undefined || value === null) return;
if (key === 'className') {
el.className = value;
} else if (key === 'text') {
el.textContent = value;
} else if (key === 'html') {
el.innerHTML = value;
} else if (key in el) {
el[key] = value;
} else {
el.setAttribute(key, value);
}
});
(Array.isArray(children) ? children : [children]).forEach(child => {
if (child) el.appendChild(child);
});
return el;
};
const applyInputStyles = (el) => {
if (!el) return el;
el.style.width = '100%';
el.style.boxSizing = 'border-box';
el.style.padding = '6px 8px';
el.style.border = '1px solid #cbd5f5';
el.style.borderRadius = '4px';
el.style.fontSize = '13px';
el.style.fontFamily = 'inherit';
return el;
};
const syncSelectOptions = (selectEl, options, selected) => {
if (!selectEl) return;
while (selectEl.firstChild) {
selectEl.removeChild(selectEl.firstChild);
}
(options || []).forEach(opt => {
const optionEl = document.createElement('option');
optionEl.value = opt.id ?? opt.value ?? '';
optionEl.textContent = opt.label ?? opt.text ?? '';
if (opt.disabled) optionEl.disabled = true;
selectEl.appendChild(optionEl);
});
if (selected !== undefined && selected !== null) {
selectEl.value = selected;
}
};
const safeRemoveComponent = (component) => {
if (!component || typeof component.remove !== 'function') return;
try {
component.remove();
} catch (err) {
log('PLACEHOLDER WARN', `Komponente konnte nicht entfernt werden: ${err && err.message ? err.message : err}`, '#b45309');
}
};
const refreshPlaceholderComponent = (component) => {
if (!component) return;
if (typeof component.updatePlaceholderState === 'function') {
try {
component.updatePlaceholderState();
} catch (err) {
log('PLACEHOLDER WARN', `updatePlaceholderState Fehler: ${err && err.message ? err.message : err}`, '#b45309');
}
} else if (typeof component.trigger === 'function') {
component.trigger('change:attributes');
}
if (component.view && typeof component.view.render === 'function') {
component.view.render();
}
if (component.em && typeof component.em.trigger === 'function') {
component.em.trigger('component:update', component);
}
};
const buildPlaceholderLabel = (payload) => {
const type = payload.type === 'database' ? 'database' : 'custom';
if (type === 'database') {
const table = (payload.table || 'TABELLE').toUpperCase();
const column = (payload.column || 'FELD').toUpperCase();
return `${table}.${column}`;
}
return (payload.key || 'PLATZHALTER').toUpperCase();
};
const buildPlaceholderText = (payload) => {
const label = buildPlaceholderLabel(payload);
return `{{${label}}}`;
};
const escapeAttr = (value) => {
return String(value || '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(//g, '>');
};
const buildPlaceholderHTML = (payload) => {
const type = payload && payload.type === 'database' ? 'database' : 'custom';
const key = payload && payload.key ? payload.key : 'UEBERSCHRIFT';
const table = payload && payload.table ? payload.table : 'tabelle';
const column = payload && payload.column ? payload.column : 'feld';
const label = buildPlaceholderText(payload || {});
const attrs = [
`data-gjs-type="${PLACEHOLDER_COMPONENT}"`,
`data-placeholder-type="${escapeAttr(type)}"`,
`contenteditable="false"`,
`class="${INLINE_PLACEHOLDER_CLASS}"`
];
if (type === 'database') {
attrs.push(`data-placeholder-table="${escapeAttr(table)}"`);
attrs.push(`data-placeholder-column="${escapeAttr(column)}"`);
} else {
attrs.push(`data-placeholder-key="${escapeAttr(key)}"`);
}
return `${label}`;
};
const buildField = (labelText, control) => {
const controlId = `bridge-placeholder-field-${Math.random().toString(36).slice(2)}`;
control.id = controlId;
const label = createEl('label', { htmlFor: controlId, text: labelText });
label.style.display = 'block';
label.style.fontWeight = '600';
label.style.fontSize = '13px';
label.style.marginBottom = '4px';
const wrapper = createEl('div', { className: 'bridge-placeholder-field' }, [label, control]);
wrapper.style.marginBottom = '12px';
return wrapper;
};
const buildTextnodeComponents = (el) => {
if (!el) return [];
const nodes = Array.from(el.childNodes || []);
const comps = [];
nodes.forEach((node) => {
if (!node) return;
if (node.nodeType === 3) {
const text = node.textContent;
if (text !== null && text !== undefined && text !== '') {
comps.push({ type: 'textnode', content: text });
}
return;
}
if (node.nodeType === 1 && node.outerHTML) {
comps.push(node.outerHTML);
}
});
return comps;
};
const reparseTextComponent = () => {};
const captureRteSelection = (rteInstance) => {
const doc = rteInstance && rteInstance.doc
? rteInstance.doc
: (rteInstance && rteInstance.el && rteInstance.el.ownerDocument) || document;
if (!doc || !doc.getSelection) return null;
const sel = doc.getSelection();
if (!sel || !sel.rangeCount) return null;
return {
doc,
range: sel.getRangeAt(0).cloneRange()
};
};
const restoreRteSelection = (snapshot) => {
if (!snapshot || !snapshot.doc || !snapshot.range) return false;
const sel = snapshot.doc.getSelection && snapshot.doc.getSelection();
if (!sel) return false;
try {
sel.removeAllRanges();
sel.addRange(snapshot.range);
return true;
} catch (err) {
log('PLACEHOLDER WARN', `Auswahl konnte nicht wiederhergestellt werden: ${err && err.message ? err.message : err}`, '#b45309');
return false;
}
};
const insertTextIntoSelection = (rteInstance, text) => {
const doc = rteInstance && rteInstance.doc
? rteInstance.doc
: (rteInstance && rteInstance.el && rteInstance.el.ownerDocument) || document;
if (!doc) return false;
const sel = doc.getSelection && doc.getSelection();
if (!sel || !sel.rangeCount) return false;
const range = sel.getRangeAt(0);
if (!range) return false;
range.deleteContents();
const textNode = doc.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return true;
};
const getRteDocument = (rteInstance) => {
return rteInstance && rteInstance.doc
? rteInstance.doc
: (rteInstance && rteInstance.el && rteInstance.el.ownerDocument) || document;
};
const insertCaretMarker = (rteInstance) => {
const doc = getRteDocument(rteInstance);
if (!doc || !doc.getSelection) return null;
const sel = doc.getSelection();
if (!sel || !sel.rangeCount) return null;
const range = sel.getRangeAt(0);
if (!range || !range.collapsed) return null;
const markerId = `bridge-placeholder-marker-${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const marker = doc.createElement('span');
marker.setAttribute(PLACEHOLDER_MARKER_ATTR, markerId);
marker.style.display = 'inline-block';
marker.style.width = '0';
marker.style.height = '0';
marker.style.padding = '0';
marker.style.margin = '0';
marker.style.lineHeight = '0';
marker.style.overflow = 'hidden';
range.insertNode(marker);
range.setStartAfter(marker);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return { doc, id: markerId };
};
const removeMarkerFromComponent = (markerInfo, rteInstance) => {
if (!markerInfo || !markerInfo.id) return false;
const doc = markerInfo.doc || getRteDocument(rteInstance);
if (!doc || !doc.querySelector) return false;
const marker = doc.querySelector(`[${PLACEHOLDER_MARKER_ATTR}="${markerInfo.id}"]`);
if (!marker || !marker.parentNode) return false;
marker.parentNode.removeChild(marker);
return true;
};
const replaceMarkerWithPlaceholder = (markerInfo, rteInstance, text) => {
if (!markerInfo || !markerInfo.id) return false;
const doc = markerInfo.doc || getRteDocument(rteInstance);
if (!doc || !doc.querySelector) return false;
const marker = doc.querySelector(`[${PLACEHOLDER_MARKER_ATTR}="${markerInfo.id}"]`);
if (!marker || !marker.parentNode) return false;
const textNode = doc.createTextNode(text);
marker.parentNode.replaceChild(textNode, marker);
const sel = doc.getSelection && doc.getSelection();
if (sel && doc.createRange) {
const range = doc.createRange();
range.setStartAfter(textNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
return true;
};
const openPlaceholderModal = (editor, component, opts = {}) => {
if (!editor) return;
const modal = editor.Modal;
if (!modal) return;
ensureInlinePlaceholderStyles();
if (component && (!component.is || !component.is(PLACEHOLDER_COMPONENT))) {
log('PLACEHOLDER WARN', 'openPlaceholderModal wurde ohne gültige Placeholder-Komponente aufgerufen.', '#b45309');
return;
}
let didSave = false;
let didCancel = false;
const fireCancel = (reason) => {
if (didSave || didCancel) return;
didCancel = true;
if (typeof opts.onCancel === 'function') {
try {
opts.onCancel({ reason, component, modal });
} catch (err) {
log('PLACEHOLDER WARN', `onCancel Fehler: ${err && err.message ? err.message : err}`, '#b45309');
}
}
};
const attrs = component && component.getAttributes ? component.getAttributes() : (opts.initial || {});
const initialType = attrs['data-placeholder-type'] || 'custom';
const initialKey = attrs['data-placeholder-key'] || 'UEBERSCHRIFT';
const initialTable = attrs['data-placeholder-table'] || '';
const initialColumn = attrs['data-placeholder-column'] || '';
const container = createEl('div', { className: 'bridge-placeholder-modal' });
container.style.padding = '8px 4px';
const form = createEl('form', { className: 'bridge-placeholder-form' });
form.style.display = 'flex';
form.style.flexDirection = 'column';
const typeSelect = applyInputStyles(createEl('select'));
[
{ id: 'custom', label: 'Placeholder (Text)' },
{ id: 'database', label: 'Placeholder (DB)' },
].forEach(opt => {
const optionEl = createEl('option', { value: opt.id, text: opt.label });
typeSelect.appendChild(optionEl);
});
typeSelect.value = initialType;
const keyInput = applyInputStyles(createEl('input', { type: 'text', value: initialKey }));
keyInput.placeholder = 'z.B. UEBERSCHRIFT';
const tableSelect = applyInputStyles(createEl('select'));
const columnSelect = applyInputStyles(createEl('select'));
tableSelect.disabled = true;
columnSelect.disabled = true;
const dbMessage = createEl('p', { className: 'bridge-placeholder-hint', text: '' });
dbMessage.style.fontSize = '12px';
dbMessage.style.margin = '4px 0 0';
dbMessage.style.color = '#475569';
const columnMessage = createEl('p', { className: 'bridge-placeholder-hint', text: '' });
columnMessage.style.fontSize = '12px';
columnMessage.style.margin = '4px 0 0';
columnMessage.style.color = '#475569';
const customWrap = createEl('div', { className: 'bridge-placeholder-section' }, [
buildField('Bezeichner', keyInput),
]);
const dbWrap = createEl('div', { className: 'bridge-placeholder-section' }, [
buildField('Tabelle', tableSelect),
dbMessage,
buildField('Feld', columnSelect),
columnMessage,
]);
const errorBox = createEl('p', { className: 'bridge-placeholder-error', text: '' });
errorBox.style.fontSize = '12px';
errorBox.style.color = '#b91c1c';
errorBox.style.margin = '4px 0 0';
form.appendChild(buildField('Typ', typeSelect));
form.appendChild(customWrap);
form.appendChild(dbWrap);
form.appendChild(errorBox);
const actions = createEl('div', { className: 'bridge-placeholder-actions' });
actions.style.marginTop = '16px';
actions.style.display = 'flex';
actions.style.justifyContent = 'flex-end';
actions.style.gap = '8px';
const cancelBtn = createEl('button', { type: 'button', text: 'Abbrechen' });
cancelBtn.style.padding = '6px 12px';
cancelBtn.style.border = '1px solid #cbd5f5';
cancelBtn.style.borderRadius = '4px';
cancelBtn.style.background = '#f8fafc';
cancelBtn.style.cursor = 'pointer';
const saveBtn = createEl('button', { type: 'submit', text: 'Speichern' });
saveBtn.style.padding = '6px 16px';
saveBtn.style.border = '1px solid #0ea5e9';
saveBtn.style.background = '#0ea5e9';
saveBtn.style.color = '#fff';
saveBtn.style.borderRadius = '4px';
saveBtn.style.cursor = 'pointer';
actions.appendChild(cancelBtn);
actions.appendChild(saveBtn);
form.appendChild(actions);
container.appendChild(form);
let tablesCache = placeholderSchemaStore.tables || [];
const toggleSections = () => {
const type = typeSelect.value || 'custom';
customWrap.style.display = type === 'custom' ? '' : 'none';
dbWrap.style.display = type === 'database' ? '' : 'none';
};
const populateColumns = (tableName, preferredColumn) => {
const table = (tablesCache || []).find(tbl => (tbl.name || '').toLowerCase() === (tableName || '').toLowerCase());
const columns = table && Array.isArray(table.columns) ? table.columns : [];
if (!columns.length) {
syncSelectOptions(columnSelect, [{ id: '', label: columns.length ? '– Feld wählen –' : 'Keine Felder verfügbar' }], '');
columnSelect.disabled = true;
columnMessage.textContent = table ? 'Diese Tabelle enthält keine Felder.' : 'Bitte zuerst eine Tabelle wählen.';
return;
}
const opts = columns.map(col => ({
id: col.name,
label: col.name + (col.type ? ` (${col.type})` : ''),
}));
syncSelectOptions(columnSelect, opts, preferredColumn || initialColumn || opts[0].id);
columnSelect.disabled = false;
columnMessage.textContent = '';
};
const populateTables = (tables) => {
tablesCache = Array.isArray(tables) ? tables : [];
if (!tablesCache.length) {
syncSelectOptions(tableSelect, [{ id: '', label: 'Keine Tabellen gefunden' }], '');
tableSelect.disabled = true;
columnSelect.disabled = true;
dbMessage.textContent = 'Kein Tabellen-Schema verfügbar. Bitte Bridge-Setup prüfen.';
columnMessage.textContent = '';
return;
}
const opts = tablesCache.map(tbl => ({ id: tbl.name, label: tbl.name }));
syncSelectOptions(tableSelect, opts, initialTable || opts[0].id);
tableSelect.disabled = false;
dbMessage.textContent = '';
populateColumns(tableSelect.value, initialColumn);
};
const loadTables = () => {
if (placeholderSchemaStore.tables && placeholderSchemaStore.tables.length) {
populateTables(placeholderSchemaStore.tables);
return;
}
dbMessage.textContent = 'Tabellen werden geladen …';
fetchPlaceholderSchema()
.then(populateTables)
.catch(() => {
populateTables([]);
});
};
if (tablesCache.length) {
populateTables(tablesCache);
} else if (placeholderSchemaStore.promise) {
placeholderSchemaStore.promise.then(populateTables).catch(() => populateTables([]));
} else if (placeholderSchemaStore.status !== false) {
fetchPlaceholderSchema()
.then(populateTables)
.catch(() => populateTables([]));
}
tableSelect.addEventListener('change', () => {
populateColumns(tableSelect.value);
});
typeSelect.addEventListener('change', () => {
toggleSections();
if (typeSelect.value === 'database' && (!tablesCache || !tablesCache.length)) {
loadTables();
}
});
cancelBtn.addEventListener('click', (e) => {
e.preventDefault();
fireCancel('cancel-button');
if (window.B && typeof window.B.allowModalCloseOnce === 'function') {
window.B.allowModalCloseOnce();
}
modal.close();
});
const applyPayload = (payload) => {
if (typeof opts.onSubmit === 'function') {
const res = opts.onSubmit(payload, { component, modal });
return res !== false;
}
if (!component || typeof component.addAttributes !== 'function') {
log('PLACEHOLDER INFO', 'Keine Ziel-Komponente gefunden – Placeholder-Daten werden verworfen.', '#888');
return true;
}
if (payload.type === 'custom') {
component.addAttributes({
'data-placeholder-type': 'custom',
'data-placeholder-key': payload.key,
'data-placeholder-table': '',
'data-placeholder-column': '',
});
} else {
component.addAttributes({
'data-placeholder-type': 'database',
'data-placeholder-key': '',
'data-placeholder-table': payload.table,
'data-placeholder-column': payload.column,
});
}
refreshPlaceholderComponent(component);
component.__bridgePlaceholderNew = false;
return true;
};
form.addEventListener('submit', (e) => {
e.preventDefault();
errorBox.textContent = '';
const type = typeSelect.value || 'custom';
if (type === 'custom') {
const key = (keyInput.value || '').trim();
if (!key) {
errorBox.textContent = 'Bitte einen Bezeichner eingeben.';
keyInput.focus();
return;
}
const payload = {
type: 'custom',
key,
table: '',
column: '',
};
if (!applyPayload(payload)) {
return;
}
} else {
const table = tableSelect.value || '';
const column = columnSelect.value || '';
if (!table || !column) {
errorBox.textContent = 'Bitte Tabelle und Feld auswählen.';
if (!table) {
tableSelect.focus();
} else {
columnSelect.focus();
}
return;
}
const payload = {
type: 'database',
key: '',
table,
column,
};
if (!applyPayload(payload)) {
return;
}
}
didSave = true;
if (window.B && typeof window.B.allowModalCloseOnce === 'function') {
window.B.allowModalCloseOnce();
}
modal.close();
});
toggleSections();
if (typeSelect.value === 'database') {
loadTables();
}
modal.setTitle('Placeholder konfigurieren');
modal.setContent(container);
if (typeof modal.onceClose === 'function') {
modal.onceClose(() => fireCancel('modal-close'));
} else if (modal.getModel && typeof modal.getModel === 'function') {
const mdl = modal.getModel();
if (mdl && typeof mdl.on === 'function') {
const handler = () => {
if (!mdl.get('open')) {
mdl.off && mdl.off('change:open', handler);
fireCancel('modal-close');
}
};
mdl.on && mdl.on('change:open', handler);
}
}
modal.open();
if (component && editor.getSelected && editor.getSelected() !== component) {
editor.select && editor.select(component);
}
};
const ensureRtePlaceholderButton = (editor) => {
const rte = editor && editor.RichTextEditor;
if (!rte || rte.__bridgePlaceholderButton) return;
rte.__bridgePlaceholderButton = true;
rte.add('bridge-placeholder', {
icon: '{}',
attributes: { title: 'Placeholder einfügen' },
result: (rteInstance) => {
const target = editor && editor.getSelected && editor.getSelected();
if (!target || !target.is || !target.is('text')) {
log('PLACEHOLDER INFO', 'Bitte zuerst ein Text-Element auswählen.', '#888');
return;
}
const selectionSnapshot = captureRteSelection(rteInstance);
if (!selectionSnapshot) {
log('PLACEHOLDER ERROR', 'Keine Text-Selektion gefunden. Bitte Cursor setzen und erneut versuchen.', 'red', 'error');
return;
}
const markerInfo = insertCaretMarker(rteInstance);
const restoreSelection = () => restoreRteSelection(selectionSnapshot);
openPlaceholderModal(editor, null, {
onCancel: () => {
if (markerInfo) {
removeMarkerFromComponent(markerInfo, rteInstance);
}
if (rteInstance && typeof rteInstance.focus === 'function') {
setTimeout(() => rteInstance.focus(), 0);
}
},
onSubmit: (payload) => {
const text = buildPlaceholderText(payload);
if (markerInfo && replaceMarkerWithPlaceholder(markerInfo, rteInstance, text)) {
if (rteInstance && typeof rteInstance.focus === 'function') {
setTimeout(() => rteInstance.focus(), 0);
}
return true;
}
if (markerInfo) {
removeMarkerFromComponent(markerInfo, rteInstance);
}
if (!restoreSelection()) {
log('PLACEHOLDER ERROR', 'Cursor-Position konnte nicht wiederhergestellt werden.', 'red', 'error');
return false;
}
if (!insertTextIntoSelection(rteInstance, text)) {
log('PLACEHOLDER ERROR', 'Placeholder konnte nicht eingefügt werden.', 'red', 'error');
return false;
}
if (rteInstance && typeof rteInstance.focus === 'function') {
setTimeout(() => rteInstance.focus(), 0);
}
return true;
}
});
},
});
log('RTE', 'Placeholder-Button im RichTextEditor registriert.', '#DAA520');
};
function addOnce(bm, id, def, category = TARGET_CAT_ID) {
try {
bm.add(id, { ...def, category });
ALL_PLACEHOLDER_BLOCK_IDS.push(id);
log('BLOCK ADD', `Block '${id}' erfolgreich hinzugefügt.`, '#B8860B');
} catch (e) {
log('BLOCK ERROR', `Fehler beim Hinzufügen von Block '${id}': ${e.message}`, 'red', 'error');
}
}
const ensureBridgeAvailability = () => {
if (placeholderSchemaStore.status !== null) {
return Promise.resolve(placeholderSchemaStore.status);
}
if (placeholderSchemaStore.statusPromise) {
return placeholderSchemaStore.statusPromise;
}
const base = B.API_KERNEL_URL || '/api.php';
const sep = base.includes('?') ? '&' : '?';
const url = `${base}${sep}action=placeholders.status`;
placeholderSchemaStore.statusPromise = fetch(url, { credentials: 'include' })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
const available = !!(data && (data.available || (data.settings && data.settings.available)));
placeholderSchemaStore.status = available;
placeholderSchemaStore.statusPromise = null;
if (!available) {
log('PLACEHOLDER INFO', 'Bridge-Placeholders nicht konfiguriert – DB-Funktionen deaktiviert.', '#64748b');
}
return available;
})
.catch(err => {
placeholderSchemaStore.status = false;
placeholderSchemaStore.statusPromise = null;
log('PLACEHOLDER WARN', `Bridge-Status konnte nicht geprüft werden: ${err && err.message ? err.message : err}`, '#b45309');
return false;
});
return placeholderSchemaStore.statusPromise;
};
const fetchPlaceholderSchema = () => {
if (placeholderSchemaStore.promise) return placeholderSchemaStore.promise;
placeholderSchemaStore.promise = ensureBridgeAvailability().then(isAvailable => {
if (!isAvailable) throw new Error('Bridge not available');
const base = B.API_KERNEL_URL || '/api.php';
const sep = base.includes('?') ? '&' : '?';
const url = `${base}${sep}action=placeholders.schema`;
return fetch(url, { credentials: 'include' })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
const tbls = data && Array.isArray(data.tables) ? data.tables : [];
placeholderSchemaStore.tables = tbls;
return placeholderSchemaStore.tables;
});
}).catch(err => {
placeholderSchemaStore.tables = [];
placeholderSchemaStore.promise = null;
const msg = err && err.message ? err.message : err;
if (msg === 'Bridge not available') {
log('PLACEHOLDER INFO', 'Schema-Abfrage übersprungen (keine Bridge verfügbar).', '#64748b');
} else {
log('PLACEHOLDER ERROR', `Schema konnte nicht geladen werden: ${msg}`, 'red', 'error');
}
throw err;
});
return placeholderSchemaStore.promise;
};
const getTraitByName = (model, name) => {
if (typeof model.getTrait === 'function') return model.getTrait(name);
const traits = model.get('traits');
if (!traits) return null;
if (typeof traits.where === 'function') {
const found = traits.where({ name });
return found && found[0];
}
if (Array.isArray(traits.models)) {
return traits.models.find(t => t.get && t.get('name') === name) || null;
}
return null;
};
const cloneValue = (value) => {
if (Array.isArray(value)) {
return value.map(cloneValue);
}
if (value && typeof value === 'object') {
const copy = {};
Object.keys(value).forEach(key => {
copy[key] = cloneValue(value[key]);
});
return copy;
}
return value;
};
const defaultPlaceholderProps = {
name: 'Placeholder',
tagName: 'span',
droppable: false,
attributes: {
'data-placeholder-type': 'custom',
'data-placeholder-key': 'UEBERSCHRIFT',
'contenteditable': 'false',
'class': INLINE_PLACEHOLDER_CLASS,
},
traits: [
{
type: 'select',
name: 'data-placeholder-type',
label: 'Typ',
options: [
{ id: 'custom', label: 'Allgemein' },
{ id: 'database', label: 'Datenbank' },
],
changeProp: true,
},
{
type: 'text',
name: 'data-placeholder-key',
label: 'Bezeichner',
placeholder: 'UEBERSCHRIFT',
changeProp: true,
},
{
type: 'select',
name: 'data-placeholder-table',
label: 'Tabelle',
options: [],
changeProp: true,
},
{
type: 'select',
name: 'data-placeholder-column',
label: 'Feld',
options: [],
changeProp: true,
},
],
toolbar: [
{
attributes: { class: 'fa fa-edit', title: 'Placeholder bearbeiten' },
command: 'bridge-placeholder:edit',
},
],
};
const applyPlaceholderDefaults = (model) => {
if (!model || typeof model.get !== 'function' || typeof model.set !== 'function') {
return;
}
Object.entries(defaultPlaceholderProps).forEach(([key, value]) => {
if (typeof model.get(key) === 'undefined') {
model.set(key, cloneValue(value));
}
});
};
const ensurePlaceholderComponent = (editor) => {
const domc = editor.DomComponents;
if (domc.getType(PLACEHOLDER_COMPONENT)) return;
ensureInlinePlaceholderStyles();
const baseType = domc.getType('text') || domc.getType('default') || {};
const BaseModel = baseType.model || editor.DomComponents.Component;
const BaseView = baseType.view || editor.DomComponents.View;
const PlaceholderModel = BaseModel.extend({
init() {
applyPlaceholderDefaults(this);
this.listenTo(this, 'change:attributes', this.updatePlaceholderState);
this.updatePlaceholderState();
fetchPlaceholderSchema()
.then(() => this.updateSchemaTraits())
.catch(() => this.updateSchemaTraits([]));
},
updatePlaceholderState() {
const attrs = this.getAttributes();
if (!attrs['data-placeholder-type']) {
this.addAttributes({ 'data-placeholder-type': 'custom' });
}
if (attrs['contenteditable'] !== 'false') {
this.addAttributes({ 'contenteditable': 'false' });
}
this.updateTraitVisibility();
this.updateSchemaTraits();
this.updateLabel();
},
updateTraitVisibility() {
const attrs = this.getAttributes();
const type = attrs['data-placeholder-type'] || 'custom';
const isDb = type === 'database';
const tableTrait = getTraitByName(this, 'data-placeholder-table');
const columnTrait = getTraitByName(this, 'data-placeholder-column');
const keyTrait = getTraitByName(this, 'data-placeholder-key');
if (tableTrait && tableTrait.view && tableTrait.view.el) {
tableTrait.view.el.style.display = isDb ? '' : 'none';
}
if (columnTrait && columnTrait.view && columnTrait.view.el) {
columnTrait.view.el.style.display = isDb ? '' : 'none';
}
if (keyTrait && keyTrait.view && keyTrait.view.el) {
keyTrait.view.el.style.display = isDb ? 'none' : '';
}
},
updateSchemaTraits(tablesOverride) {
const tables = Array.isArray(tablesOverride) ? tablesOverride : placeholderSchemaStore.tables;
const tableTrait = getTraitByName(this, 'data-placeholder-table');
const columnTrait = getTraitByName(this, 'data-placeholder-column');
const loading = !tablesOverride && placeholderSchemaStore.promise && !tables.length;
if (tableTrait) {
let opts;
if (tables.length) {
opts = tables.map(function (tbl) { return { id: tbl.name, label: tbl.name }; });
} else if (loading) {
opts = [{ id: '', label: 'Tabellen werden geladen…', disabled: true }];
} else {
opts = [{ id: '', label: 'Keine Tabellen verfügbar', disabled: true }];
}
setTraitOptions(tableTrait, opts);
}
if (columnTrait) {
const attrs = this.getAttributes();
const tableName = (attrs['data-placeholder-table'] || '').toLowerCase();
const table = tables.find(function (tbl) {
return (tbl.name || '').toLowerCase() === tableName;
});
const columns = table && Array.isArray(table.columns) ? table.columns : [];
const colOpts = columns.length
? columns.map(function (col) { return { id: col.name, label: col.name + (col.type ? ' (' + col.type + ')' : '') }; })
: [{ id: '', label: table ? 'Keine Felder' : 'Feld wählen', disabled: !table }];
setTraitOptions(columnTrait, colOpts);
}
},
updateLabel() {
const attrs = this.getAttributes();
const type = attrs['data-placeholder-type'] || 'custom';
let label;
if (type === 'database') {
const table = attrs['data-placeholder-table'] || 'TABELLE';
const column = attrs['data-placeholder-column'] || 'FELD';
label = (table + '.' + column).toUpperCase();
} else {
label = (attrs['data-placeholder-key'] || 'PLATZHALTER').toUpperCase();
}
const text = '{{' + label + '}}';
const comps = this.components();
const onlyText = comps.length === 1 && comps.at(0).is('textnode');
if (onlyText) {
comps.at(0).set('content', text);
} else {
comps.reset([{ type: 'textnode', content: text }]);
}
}
});
const PlaceholderView = BaseView.extend({
render() {
BaseView.prototype.render.apply(this, arguments);
this.el.classList.add('placeholder-block');
this.el.classList.add(INLINE_PLACEHOLDER_CLASS);
this.el.setAttribute('contenteditable', 'false');
this.el.style.display = 'inline-flex';
this.el.style.alignItems = 'center';
this.el.style.gap = '4px';
this.el.style.padding = '2px 8px';
this.el.style.border = '1px dashed #94a3b8';
this.el.style.borderRadius = '6px';
this.el.style.background = '#f1f5f9';
this.el.style.fontFamily = 'monospace';
this.el.style.fontSize = '12px';
return this;
},
});
domc.addType(PLACEHOLDER_COMPONENT, {
model: PlaceholderModel,
view: PlaceholderView,
isComponent(el) {
if (el && el.hasAttribute && el.hasAttribute('data-placeholder-type')) {
return { type: PLACEHOLDER_COMPONENT };
}
return false;
},
});
};
const patchTextComponentDroppable = (component) => {
if (!component || !component.is || !component.is('text')) {
return;
}
if (component.__bridgePlaceholderDroppable) {
return;
}
component.__bridgePlaceholderDroppable = true;
const originalDroppable = component.get('droppable');
const allowFn = function (source, cmp) {
if (cmp && cmp.get && cmp.get('type') === PLACEHOLDER_COMPONENT) {
return true;
}
if (typeof originalDroppable === 'function') {
return originalDroppable.call(this, source, cmp);
}
if (typeof originalDroppable === 'undefined') {
return true;
}
return originalDroppable;
};
component.set('droppable', allowFn);
};
const ensureTextSupportsPlaceholders = (editor) => {
const wrapper = editor.getWrapper && editor.getWrapper();
if (wrapper) {
if (typeof wrapper.findType === 'function') {
wrapper.findType('text').forEach(patchTextComponentDroppable);
} else if (typeof wrapper.find === 'function') {
wrapper.find('[data-gjs-type="text"]').forEach(patchTextComponentDroppable);
}
}
editor.on('component:add', (cmp) => patchTextComponentDroppable(cmp));
};
function setTraitOptions(trait, options) {
if (!trait) return;
trait.set('options', options);
if (trait.view && typeof trait.view.render === 'function') {
trait.view.render();
}
}
function register(editor) {
log('EXECUTION', `Starte Placeholder-Registrierung für ${TARGET_CAT_ID}.`, '#DAA520');
const bm = editor.BlockManager;
if (!placeholderSchemaStore.promise && placeholderSchemaStore.status !== false) {
fetchPlaceholderSchema().catch(() => {});
}
const existingCmd = editor.Commands.get('bridge-placeholder:edit');
if (!existingCmd || existingCmd.__bridgePlaceholderStub) {
editor.Commands.add('bridge-placeholder:edit', {
run(ed, sender, opts = {}) {
const component = opts.component || ed.getSelected();
if (component && component.is && component.is(PLACEHOLDER_COMPONENT)) {
openPlaceholderModal(ed, component);
} else {
log('PLACEHOLDER INFO', 'Kein Placeholder ausgewählt.', '#888');
}
},
});
}
if (!editor.__bridgePlaceholderEventsBound) {
editor.__bridgePlaceholderEventsBound = true;
}
const bindRteButton = () => ensureRtePlaceholderButton(editor);
if (editor.RichTextEditor) {
bindRteButton();
} else if (typeof editor.on === 'function') {
editor.on('load', bindRteButton, { once: true });
}
addOnce(bm, 'cust-placeholder-custom', {
id: 'cust-placeholder-custom',
label: '🔖 Placeholder (Text)',
content: `{{UEBERSCHRIFT}}`
});
const ensureDbBlock = (tables) => {
const fallback = { table: 'tabelle', column: 'feld' };
let tableName = fallback.table;
let columnName = fallback.column;
if (Array.isArray(tables) && tables.length) {
const tableWithColumns = tables.find(tbl => Array.isArray(tbl.columns) && tbl.columns.length) || tables[0];
tableName = tableWithColumns && tableWithColumns.name ? tableWithColumns.name : fallback.table;
const firstColumn = tableWithColumns && Array.isArray(tableWithColumns.columns) && tableWithColumns.columns[0];
columnName = firstColumn && firstColumn.name ? firstColumn.name : fallback.column;
}
const payload = { type: 'database', table: tableName, column: columnName };
const content = buildPlaceholderHTML(payload);
const blockId = 'cust-placeholder-db';
const label = '🗄️ Placeholder (DB)';
const existing = typeof bm.get === 'function' ? bm.get(blockId) : null;
if (existing && typeof existing.set === 'function') {
existing.set('content', content);
existing.set('label', label);
} else {
addOnce(bm, blockId, {
id: blockId,
label,
content,
});
}
};
ensureDbBlock();
fetchPlaceholderSchema()
.then((tables) => {
ensureDbBlock(tables);
})
.catch(() => {
log('PLACEHOLDER WARN', 'DB Placeholder Schema konnte nicht geladen werden – Fallback bleibt aktiv.', '#b45309');
});
log('SUCCESS', `Placeholder-Registrierung abgeschlossen. ${ALL_PLACEHOLDER_BLOCK_IDS.length} Blöcke erstellt.`, '#008000', 'info');
}
window.BridgeBlocksPlaceholder = {
IDS: ALL_PLACEHOLDER_BLOCK_IDS,
register,
openModal: openPlaceholderModal
};
if (B && B.registerGrapesJSPlugin && typeof register === 'function') {
B.registerGrapesJSPlugin('bridge-blocks-placeholder', register);
log('PLUGIN REGISTER', `'bridge-blocks-placeholder' erfolgreich registriert.`, '#008000');
} else {
log('CRITICAL ERROR', `BridgeParts oder registerGrapesJSPlugin fehlt! Placeholder Plugin-Registrierung gescheitert.`, 'red', 'error');
}
})();