Files
GDPR-Content-Blocker/gdpr-content-blocker/assets/frontend.js
s4luorth ad7aad1b74 fix: weissraum auch bei inline-hoehe + oEmbed (::before) weg (v1.4.5)
Zwei faelle, die 1.4.4 nicht abdeckte:
- Eigenes iframe im HTML-widget liegt in <div style="height:800px"> - inline-
  hoehe, keine klasse. Mein klassenbasiertes einklappen verfehlte das.
- WordPress-oEmbed (testbeitrag, ohne Elementor) reserviert die hoehe ueber
  einen ::before-padding-trick, nicht ueber aspect-ratio.

Neu: statt inline-styles zu manipulieren wird per attribut [data-cb-collapsed]
+ EINER eingeschleusten CSS-regel alles neutralisiert (aspect-ratio, min/height,
::before/::after padding-hack, inline height via !important). Original-styles
bleiben unangetastet -> beim laden kommt die volle groesse exakt zurueck.
shouldCollapse() greift jetzt auch bei inline-hoehe und computed aspect-ratio,
aber nur bei "engen" wrappern (<=1 kind), damit keine ganze section schrumpft.
Logik isoliert getestet.

+ version 1.4.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:36:01 +02:00

471 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
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' );
const placeholder = buildPlaceholder( svc, embed, {} );
// These widgets are built entirely by JS (no server iframe we could
// catch). Rather than guess the embed size, reload on consent so
// Elementor renders the player natively at its ORIGINAL size. Drop the
// "load once" option (it can't apply here).
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();
}
} )();