Der reload-ansatz aus 1.4.4 fuer e-youtube-base war unzuverlaessig (Elementor rendert nach reload nicht sicher nativ). Zurueck zum direkten weg: aus dem "source"-key habe ich die embed-URL -> ich baue das iframe bei einwilligung selbst (unabhaengig von Elementor/iframe_api). Das iframe bekommt eine responsive 16:9-aspect-ratio (data-cb-aspect) und laedt damit voll und in originalproportionen, auch ohne definite parent-hoehe. Reload-pfad bleibt nur fuer den fall ganz ohne URL (video_type-fallback). Weissraum-einklappen unveraendert. node --check ok. + version 1.4.6. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
485 lines
17 KiB
JavaScript
485 lines
17 KiB
JavaScript
/**
|
||
* Content Blocker – frontend.js
|
||
* Click-to-load: the real iframe is CREATED only after the user actively
|
||
* consents per service. Consent is stored in localStorage per service id.
|
||
*/
|
||
|
||
( function () {
|
||
'use strict';
|
||
|
||
const STORAGE_PREFIX = 'cb_consent_';
|
||
const CFG = window.cbConfig || { services: [], i18n: {} };
|
||
|
||
/* ───────────────────────── consent storage ───────────────────────── */
|
||
|
||
function hasConsent( serviceId ) {
|
||
try {
|
||
return localStorage.getItem( STORAGE_PREFIX + serviceId ) === '1';
|
||
} catch ( e ) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function grantConsent( serviceId ) {
|
||
try {
|
||
localStorage.setItem( STORAGE_PREFIX + serviceId, '1' );
|
||
} catch ( e ) {
|
||
// localStorage unavailable; allow the load for this session only.
|
||
}
|
||
// Release any third-party <script> blocked for this service (handled by
|
||
// the early head guard, which exposes cbActivateScripts).
|
||
if ( typeof window.cbActivateScripts === 'function' ) {
|
||
window.cbActivateScripts( serviceId );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Public: grant consent for a service from a custom control
|
||
* ([content_blocker_consent]) — used for script-only services that have no
|
||
* visible embed. Reloads so blocked scripts/embeds load cleanly.
|
||
*/
|
||
window.cbConsent = function ( serviceId ) {
|
||
if ( ! serviceId ) {
|
||
return;
|
||
}
|
||
grantConsent( serviceId );
|
||
window.location.reload();
|
||
};
|
||
|
||
/* ─────────────── height-reservation (no whitespace) ─────────────── */
|
||
|
||
// Known wrappers that force a fixed aspect ratio / height around an embed.
|
||
const WRAP_SEL = '.e-youtube-base, .e-vimeo-base, .elementor-fit-aspect-ratio, '
|
||
+ '.elementor-open-inline, .elementor-wrapper, .wp-has-aspect-ratio, '
|
||
+ '.wp-block-embed__wrapper, .elementor-widget-video';
|
||
|
||
// One injected stylesheet neutralises every height reservation on a collapsed
|
||
// wrapper — CSS aspect-ratio, min/explicit height, the ::before padding-hack
|
||
// (WordPress oEmbed) and inline `height:Xpx`. Using a rule (with !important)
|
||
// means we never touch the element's own inline style, so restoring is just
|
||
// removing the attribute and the original size comes back untouched.
|
||
function ensureCollapseStyle() {
|
||
if ( document.getElementById( 'cb-collapse-style' ) ) return;
|
||
const st = document.createElement( 'style' );
|
||
st.id = 'cb-collapse-style';
|
||
st.textContent =
|
||
'[data-cb-collapsed]{aspect-ratio:auto !important;min-height:0 !important;height:auto !important;}'
|
||
+ '[data-cb-collapsed]::before,[data-cb-collapsed]::after{display:none !important;padding-top:0 !important;content:none !important;}';
|
||
( document.head || document.documentElement ).appendChild( st );
|
||
}
|
||
|
||
function shouldCollapse( el ) {
|
||
if ( ! el.matches ) return false;
|
||
if ( el.matches( WRAP_SEL ) ) return true;
|
||
// Otherwise only tight single-child wrappers, so we never shrink a section
|
||
// that also holds other content.
|
||
if ( el.childElementCount > 1 ) return false;
|
||
if ( /height\s*:\s*\d/i.test( el.getAttribute( 'style' ) || '' ) ) return true;
|
||
try {
|
||
const cs = getComputedStyle( el );
|
||
if ( cs.aspectRatio && cs.aspectRatio !== 'auto' ) return true;
|
||
if ( parseFloat( cs.minHeight ) > 80 ) return true;
|
||
} catch ( e ) { /* ignore */ }
|
||
return false;
|
||
}
|
||
|
||
function collapseAncestors( blocker ) {
|
||
ensureCollapseStyle();
|
||
let el = blocker.parentElement;
|
||
let n = 0;
|
||
while ( el && n < 5 ) {
|
||
if ( shouldCollapse( el ) ) {
|
||
el.setAttribute( 'data-cb-collapsed', '1' );
|
||
}
|
||
el = el.parentElement;
|
||
n++;
|
||
}
|
||
}
|
||
|
||
function restoreCollapsed( node ) {
|
||
let el = node.parentElement;
|
||
let n = 0;
|
||
while ( el && n < 6 ) {
|
||
if ( el.getAttribute && el.getAttribute( 'data-cb-collapsed' ) === '1' ) {
|
||
el.removeAttribute( 'data-cb-collapsed' );
|
||
}
|
||
el = el.parentElement;
|
||
n++;
|
||
}
|
||
}
|
||
|
||
/* ───────────────────────── iframe loading ────────────────────────── */
|
||
|
||
/** Replace a .cb-blocker element with the real iframe. src comes from data-src. */
|
||
function loadContent( blockerEl ) {
|
||
const src = blockerEl.dataset.src;
|
||
if ( ! src ) {
|
||
blockerEl.remove();
|
||
return;
|
||
}
|
||
|
||
// Give the wrappers their reserved size back so the embed loads full-size.
|
||
restoreCollapsed( blockerEl );
|
||
|
||
const iframe = document.createElement( 'iframe' );
|
||
|
||
if ( blockerEl.dataset.width ) iframe.width = blockerEl.dataset.width;
|
||
if ( blockerEl.dataset.height ) iframe.height = blockerEl.dataset.height;
|
||
|
||
// Responsive aspect (Elementor video widgets we build ourselves): full
|
||
// width at the given ratio, so it loads at its original proportions even
|
||
// without a definite parent height.
|
||
if ( blockerEl.dataset.cbAspect ) {
|
||
iframe.style.width = '100%';
|
||
iframe.style.height = 'auto';
|
||
iframe.style.aspectRatio = blockerEl.dataset.cbAspect;
|
||
}
|
||
iframe.style.border = '0';
|
||
|
||
iframe.setAttribute( 'loading', 'lazy' );
|
||
iframe.setAttribute( 'allowfullscreen', '' );
|
||
iframe.setAttribute( 'referrerpolicy', 'no-referrer-when-downgrade' );
|
||
iframe.setAttribute( 'data-cb-loaded', '1' ); // mark as ours (observer skips)
|
||
|
||
// Set src last — this is the moment the network request is made.
|
||
iframe.src = src;
|
||
|
||
blockerEl.parentNode.replaceChild( iframe, blockerEl );
|
||
}
|
||
|
||
function loadPreConsented() {
|
||
document.querySelectorAll( '.cb-blocker[data-cb-id]' ).forEach( function ( el ) {
|
||
const id = el.dataset.cbId;
|
||
if ( id && hasConsent( id ) ) {
|
||
loadContent( el );
|
||
}
|
||
} );
|
||
}
|
||
|
||
/* ───────────────────────── consent buttons ───────────────────────── */
|
||
|
||
function attachButtons() {
|
||
document.addEventListener( 'click', function ( e ) {
|
||
const btn = e.target.closest( '.cb-blocker__button' );
|
||
if ( ! btn ) return;
|
||
|
||
const serviceId = btn.dataset.cbId;
|
||
if ( ! serviceId ) return;
|
||
|
||
const wrapper = btn.closest( '.cb-blocker' );
|
||
|
||
// Placeholder without its own embed URL (e.g. an Elementor widget we
|
||
// couldn't read the URL from): persist consent and reload so the
|
||
// original embed renders normally.
|
||
if ( wrapper && wrapper.getAttribute( 'data-cb-reload' ) === '1' ) {
|
||
grantConsent( serviceId );
|
||
window.location.reload();
|
||
return;
|
||
}
|
||
|
||
// "Remember" checkbox (default checked): persist consent only when
|
||
// ticked; otherwise load this embed once without storing consent.
|
||
const remember = wrapper
|
||
? wrapper.querySelector( '.cb-blocker__remember-cb' )
|
||
: null;
|
||
if ( ! remember || remember.checked ) {
|
||
grantConsent( serviceId );
|
||
}
|
||
|
||
if ( wrapper ) {
|
||
loadContent( wrapper );
|
||
}
|
||
} );
|
||
}
|
||
|
||
/* ───────────────────────── revoke (Art. 7 (3)) ───────────────────── */
|
||
|
||
window.cbRevokeAll = function () {
|
||
try {
|
||
const keysToRemove = [];
|
||
for ( let i = 0; i < localStorage.length; i++ ) {
|
||
const key = localStorage.key( i );
|
||
if ( key && key.indexOf( STORAGE_PREFIX ) === 0 ) {
|
||
keysToRemove.push( key );
|
||
}
|
||
}
|
||
keysToRemove.forEach( function ( k ) {
|
||
localStorage.removeItem( k );
|
||
} );
|
||
} catch ( e ) {
|
||
// Silently ignore if localStorage is unavailable.
|
||
}
|
||
window.location.reload();
|
||
};
|
||
|
||
/* ─────────────── client-side detection (JS-injected iframes) ─────────────── */
|
||
|
||
/** Find a configured service whose match pattern is contained in the URL. */
|
||
function findService( url ) {
|
||
if ( ! url ) return null;
|
||
const list = CFG.services || [];
|
||
for ( let i = 0; i < list.length; i++ ) {
|
||
if ( list[ i ].match && url.indexOf( list[ i ].match ) !== -1 ) {
|
||
return list[ i ];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Convert a YouTube/Vimeo watch URL into its embeddable URL. */
|
||
function toEmbedUrl( url ) {
|
||
let m = url.match( /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/ );
|
||
if ( m ) return 'https://www.youtube.com/embed/' + m[ 1 ];
|
||
m = url.match( /(?:player\.)?vimeo\.com\/(?:video\/)?(\d+)/ );
|
||
if ( m ) return 'https://player.vimeo.com/video/' + m[ 1 ];
|
||
return url;
|
||
}
|
||
|
||
/** Build a consent placeholder element (mirrors PHP render_placeholder). */
|
||
function buildPlaceholder( svc, src, dims ) {
|
||
const i18n = CFG.i18n || {};
|
||
dims = dims || {};
|
||
|
||
const wrap = document.createElement( 'div' );
|
||
wrap.className = 'cb-blocker';
|
||
wrap.setAttribute( 'data-cb-id', svc.id );
|
||
wrap.dataset.src = src;
|
||
// Keep the dims for the iframe we create on consent, but do NOT reserve
|
||
// height — the placeholder is sized to its own content (no empty space).
|
||
if ( dims.width ) wrap.dataset.width = dims.width;
|
||
if ( dims.height ) wrap.dataset.height = dims.height;
|
||
|
||
const inner = document.createElement( 'div' );
|
||
inner.className = 'cb-blocker__inner';
|
||
|
||
const text = document.createElement( 'p' );
|
||
text.className = 'cb-blocker__text';
|
||
if ( svc.placeholder ) {
|
||
text.textContent = svc.placeholder;
|
||
} else {
|
||
const tpl = i18n.defaultText || '%s';
|
||
const parts = tpl.split( '%s' );
|
||
text.appendChild( document.createTextNode( parts[ 0 ] || '' ) );
|
||
const strong = document.createElement( 'strong' );
|
||
strong.textContent = svc.name;
|
||
text.appendChild( strong );
|
||
text.appendChild( document.createTextNode( parts[ 1 ] || '' ) );
|
||
}
|
||
inner.appendChild( text );
|
||
|
||
const rec = document.createElement( 'p' );
|
||
rec.className = 'cb-blocker__recipient';
|
||
const recLabel = document.createElement( 'strong' );
|
||
recLabel.textContent = ( i18n.recipient || 'Empfänger:' ) + ' ';
|
||
rec.appendChild( recLabel );
|
||
rec.appendChild( document.createTextNode( svc.recipient || '' ) );
|
||
if ( svc.third_country ) {
|
||
rec.appendChild( document.createTextNode( ' — ' ) );
|
||
const tc = document.createElement( 'span' );
|
||
tc.className = 'cb-blocker__third-country';
|
||
tc.textContent = i18n.thirdCountry || '';
|
||
rec.appendChild( tc );
|
||
}
|
||
inner.appendChild( rec );
|
||
|
||
const pur = document.createElement( 'p' );
|
||
pur.className = 'cb-blocker__purpose';
|
||
const purLabel = document.createElement( 'strong' );
|
||
purLabel.textContent = ( i18n.purpose || 'Zweck:' ) + ' ';
|
||
pur.appendChild( purLabel );
|
||
pur.appendChild( document.createTextNode( svc.purpose || '' ) );
|
||
inner.appendChild( pur );
|
||
|
||
if ( svc.privacy_url ) {
|
||
const p = document.createElement( 'p' );
|
||
const a = document.createElement( 'a' );
|
||
a.className = 'cb-blocker__privacy-link';
|
||
a.href = svc.privacy_url;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
a.textContent = i18n.privacy || 'Datenschutz';
|
||
p.appendChild( a );
|
||
inner.appendChild( p );
|
||
}
|
||
|
||
const btn = document.createElement( 'button' );
|
||
btn.type = 'button';
|
||
btn.className = 'cb-blocker__button';
|
||
btn.setAttribute( 'data-cb-id', svc.id );
|
||
btn.textContent = ( i18n.load || '%s' ).replace( '%s', svc.name );
|
||
inner.appendChild( btn );
|
||
|
||
const label = document.createElement( 'label' );
|
||
label.className = 'cb-blocker__remember';
|
||
const cb = document.createElement( 'input' );
|
||
cb.type = 'checkbox';
|
||
cb.className = 'cb-blocker__remember-cb';
|
||
cb.checked = true;
|
||
label.appendChild( cb );
|
||
label.appendChild( document.createTextNode( ' ' + ( i18n.remember || '' ) ) );
|
||
inner.appendChild( label );
|
||
|
||
wrap.appendChild( inner );
|
||
return wrap;
|
||
}
|
||
|
||
/** Replace a freshly seen iframe with a placeholder (or let it load if consented). */
|
||
function handleIframe( iframe ) {
|
||
if ( iframe.dataset.cbLoaded ) return; // one we created
|
||
if ( iframe.closest( '.cb-blocker' ) ) return; // inside a placeholder
|
||
const rawSrc = iframe.getAttribute( 'src' ) || '';
|
||
const svc = findService( rawSrc );
|
||
if ( ! svc ) return;
|
||
|
||
if ( hasConsent( svc.id ) ) return; // already consented → leave it
|
||
|
||
iframe.setAttribute( 'src', 'about:blank' ); // stop the request ASAP
|
||
const dims = {
|
||
width: iframe.getAttribute( 'width' ) || '',
|
||
height: iframe.getAttribute( 'height' ) || '',
|
||
};
|
||
const placeholder = buildPlaceholder( svc, rawSrc, dims );
|
||
if ( iframe.parentNode ) {
|
||
iframe.parentNode.replaceChild( placeholder, iframe );
|
||
collapseAncestors( placeholder );
|
||
}
|
||
}
|
||
|
||
/** Reconstruct a YouTube watch URL from a thumbnail (i.ytimg.com/vi/ID/…). */
|
||
function ytIdFromThumb( s ) {
|
||
const m = String( s || '' ).match( /(?:i\.ytimg\.com|img\.youtube\.com)\/vi\/([\w-]{6,})\// );
|
||
return m ? m[ 1 ] : '';
|
||
}
|
||
|
||
/**
|
||
* Find the embedded video URL of an Elementor video widget. Elementor often
|
||
* does NOT put the URL in data-settings (only {"video_type":"youtube"}) and
|
||
* builds the player via the JS API — so we look in several places.
|
||
*/
|
||
function videoUrlForWidget( widget ) {
|
||
const ds = widget.getAttribute( 'data-settings' ) || '';
|
||
|
||
// 1) Explicit URL keys in data-settings (older/other Elementor versions).
|
||
try {
|
||
const s = JSON.parse( ds || '{}' );
|
||
const u = s.youtube_url || s.vimeo_url || s.video_url || s.external_url
|
||
|| s.hosted_url || s.source || s.url || ( s.link && s.link.url ) || '';
|
||
if ( u ) return u;
|
||
} catch ( e ) { /* not JSON / no keys */ }
|
||
|
||
// 2) Any youtube/vimeo URL anywhere in the data-settings string (JSON values
|
||
// are quoted, so stop at a quote/space; tolerate escaped slashes \/).
|
||
const m = ds.match( /https?:[^"\s]*?(?:youtube\.com|youtu\.be|vimeo\.com)[^"\s]*/i );
|
||
if ( m ) return m[ 0 ].replace( /\\\//g, '/' ).replace( /\\/g, '' );
|
||
|
||
// 3) A server-rendered iframe already inside the widget.
|
||
const ifr = widget.querySelector( 'iframe[src]' );
|
||
if ( ifr ) return ifr.getAttribute( 'src' );
|
||
|
||
// 4) A YouTube thumbnail (lazyload / image-overlay mode) → rebuild the URL.
|
||
const thumb = widget.querySelector(
|
||
'img[src*="ytimg.com/vi/"], img[src*="img.youtube.com/vi/"], [style*="ytimg.com/vi/"]'
|
||
);
|
||
if ( thumb ) {
|
||
const id = ytIdFromThumb( thumb.getAttribute( 'src' ) || thumb.getAttribute( 'style' ) || '' );
|
||
if ( id ) return 'https://www.youtube.com/watch?v=' + id;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** Elementor video widgets build the player via JS — replace them with our
|
||
* consent placeholder before that happens. */
|
||
function handleElementorVideos() {
|
||
const widgets = document.querySelectorAll(
|
||
'.elementor-widget-video, .elementor-widget[data-widget_type^="video"], '
|
||
+ '.e-youtube-base, .e-vimeo-base, [data-e-type^="e-youtube"], [data-e-type^="e-vimeo"]'
|
||
);
|
||
widgets.forEach( function ( widget ) {
|
||
const container = widget.querySelector( '.elementor-widget-container' ) || widget;
|
||
// Already turned into a placeholder (e.g. by the server-side autodetect
|
||
// catching a rendered iframe) → leave it.
|
||
if ( container.querySelector( '.cb-blocker' ) ) return;
|
||
|
||
const raw = videoUrlForWidget( widget );
|
||
const embed = raw ? toEmbedUrl( raw ) : '';
|
||
let svc = raw ? ( findService( raw ) || findService( embed ) ) : null;
|
||
|
||
// Fallback: no URL in the DOM (Elementor only kept {"video_type":"…"}).
|
||
// Identify the service by the video type so we can still show consent.
|
||
if ( ! svc ) {
|
||
const vt = ( widget.getAttribute( 'data-settings' ) || '' )
|
||
.match( /"video_type"\s*:\s*"(youtube|vimeo|dailymotion)"/i );
|
||
if ( vt ) {
|
||
svc = findService( vt[ 1 ] + '.com' );
|
||
}
|
||
}
|
||
if ( ! svc ) return;
|
||
|
||
if ( hasConsent( svc.id ) ) return; // consented → let Elementor build it
|
||
|
||
// Neutralise Elementor so it won't build the player / load the overlay.
|
||
widget.removeAttribute( 'data-settings' );
|
||
|
||
// With an embed URL we build the iframe ourselves on consent (reliable,
|
||
// independent of Elementor/iframe_api). The iframe gets a responsive
|
||
// 16:9 aspect so it loads full-width at its original proportions.
|
||
// Without a URL we fall back to reloading so Elementor renders it.
|
||
const placeholder = buildPlaceholder( svc, embed, {} );
|
||
if ( embed ) {
|
||
placeholder.dataset.cbAspect = '16 / 9';
|
||
} else {
|
||
placeholder.setAttribute( 'data-cb-reload', '1' );
|
||
const remember = placeholder.querySelector( '.cb-blocker__remember' );
|
||
if ( remember ) remember.remove();
|
||
}
|
||
|
||
container.innerHTML = '';
|
||
container.appendChild( placeholder );
|
||
// Collapse the wrapper's reserved aspect height so there is no
|
||
// whitespace while the (small) placeholder is shown.
|
||
collapseAncestors( placeholder );
|
||
} );
|
||
}
|
||
|
||
function scanExistingIframes() {
|
||
document.querySelectorAll( 'iframe[src]' ).forEach( handleIframe );
|
||
}
|
||
|
||
function startObserver() {
|
||
if ( typeof MutationObserver === 'undefined' ) return;
|
||
const obs = new MutationObserver( function ( mutations ) {
|
||
for ( const m of mutations ) {
|
||
for ( const node of m.addedNodes ) {
|
||
if ( node.nodeType !== 1 ) continue;
|
||
if ( node.tagName === 'IFRAME' ) {
|
||
handleIframe( node );
|
||
} else if ( node.querySelectorAll ) {
|
||
node.querySelectorAll( 'iframe[src]' ).forEach( handleIframe );
|
||
}
|
||
}
|
||
}
|
||
} );
|
||
obs.observe( document.documentElement, { childList: true, subtree: true } );
|
||
}
|
||
|
||
/* ───────────────────────── bootstrap ─────────────────────────────── */
|
||
|
||
function init() {
|
||
loadPreConsented();
|
||
attachButtons();
|
||
handleElementorVideos();
|
||
scanExistingIframes();
|
||
startObserver();
|
||
// Collapse the reserved aspect height around every remaining placeholder
|
||
// (also the server-rendered ones from the PHP autodetect) so the blocker
|
||
// shows no empty space below it.
|
||
document.querySelectorAll( '.cb-blocker' ).forEach( collapseAncestors );
|
||
}
|
||
|
||
if ( document.readyState === 'loading' ) {
|
||
document.addEventListener( 'DOMContentLoaded', init );
|
||
} else {
|
||
init();
|
||
}
|
||
} )();
|