1/* 2 * FSK-Link an jeder FSK-Angabe eines Films ("FSK 12 ?") und die Erklaerung 3 * "Bedeutung der FSK-Freigaben" als Fenster ueber der Seite. Das ist der EINZIGE Weg zur 4 * Erklaerung (Thomas, 29.09.2026): kein Menuepunkt, kein Fusslink, /fsk ist nirgends verlinkt. 5 * 6 * Eingebunden in allen Layouts ueber resources/views/shared/fsk-info-script.blade.php 7 * (nur Kinos in Deutschland). Kein Build noetig: bewusst ES5, laeuft so, wie es hier steht. 8 * Inhalt: GET /fsk (shared/fsk-freigaben.blade.php), Aussehen: public/css/fsk.css. 9 * 10 * Erkannt werden die FSK-Angaben aller Straenge, auch wenn sie erst spaeter per JS entstehen: 11 * - Text "FSK 12" (Premium-Pillen und Meta-Zeilen, barrierefrei) -> Text wird zum Link 12 * - nextGen-Symbol <span class="icon-fsk-12 ...">, Kunde 307 <span class="fsk">12</span> 13 * - classic-Piktogramm <img src=".../images/pictogramme/<knr>/12.png"> und Detail "#fsk .value" 14 * -> "?" daneben 15 */ 16(function () { 17 'use strict'; 18 19 if (window.cpFskInfo || !document.querySelector || !window.MutationObserver || !window.Promise) { 20 return; 21 } 22 window.cpFskInfo = true; 23 24 var script = document.currentScript; 25 var CONTENT_URL = (script && script.getAttribute('data-url')) || '/fsk'; 26 var CSS_URL = (script && script.getAttribute('data-css')) || '/css/fsk.css'; 27 var TITLE = 'Bedeutung der FSK-Freigaben'; 28 var LABEL = 'Was bedeuten die FSK-Freigaben?'; 29 var TEXT_RE = /FSK:?\s*(?:ab\s*)?(?:0|6|12|16|18)(?![0-9])/; 30 var VALUES = { '0': true, '6': true, '12': true, '16': true, '18': true }; 31 var SKIP = 'a, button, label, select, option, textarea, input, script, style, noscript, template, summary, [contenteditable], .cp-fsk, .cp-fsk-dialog, [data-fsk-skip]'; 32 var OWN = '.cp-fsk, .cp-fsk-dialog, [data-fsk-skip]'; 33 34 var cssPromise = null; 35 var contentPromise = null; 36 var dialog = null; 37 var lastTrigger = null; 38 var queue = []; 39 var queued = false; 40 41 function loadCss() { 42 if (!cssPromise) { 43 cssPromise = new Promise(function (resolve) { 44 var link = document.createElement('link'); 45 link.rel = 'stylesheet'; 46 link.href = CSS_URL; 47 link.onload = resolve; 48 link.onerror = resolve; 49 document.head.appendChild(link); 50 }); 51 } 52 return cssPromise; 53 } 54 55 function makeLink(text) { 56 var a = document.createElement('a'); 57 a.href = CONTENT_URL; 58 a.className = text ? 'cp-fsk-link' : 'cp-fsk-link cp-fsk-link--solo'; 59 a.setAttribute('data-fsk-info', ''); 60 a.setAttribute('aria-haspopup', 'dialog'); 61 a.title = LABEL; 62 if (text) { 63 a.appendChild(document.createTextNode(text)); 64 } 65 var q = document.createElement('span'); 66 q.className = 'cp-fsk-q'; 67 q.setAttribute('aria-hidden', 'true'); 68 q.textContent = '?'; 69 a.appendChild(q); 70 var sr = document.createElement('span'); 71 sr.className = 'cp-fsk-sr'; 72 sr.textContent = text ? ' â ' + LABEL : LABEL; 73 a.appendChild(sr); 74 return a; 75 } 76 77 function isOwn(el) { 78 return !!el.closest(OWN); 79 } 80 81 function hasLinkAfter(el) { 82 var next = el.nextElementSibling; 83 return !!(next && next.classList && next.classList.contains('cp-fsk-link')); 84 } 85 86 function acceptText(node) { 87 var value = node.nodeValue; 88 if (!value || value.indexOf('FSK') === -1 || !TEXT_RE.test(value)) { 89 return false; 90 } 91 var parent = node.parentElement; 92 return !!parent && !parent.closest(SKIP); 93 } 94 95 function wrapText(node) { 96 var match = TEXT_RE.exec(node.nodeValue); 97 if (!match || !node.parentNode) { 98 return; 99 } 100 var hit = node.splitText(match.index); 101 var after = hit.splitText(match[0].length); 102 hit.parentNode.replaceChild(makeLink(match[0]), hit); 103 if (after.nodeValue && TEXT_RE.test(after.nodeValue)) { 104 wrapText(after); 105 } 106 } 107 108 function each(root, selector, fn) { 109 if (root.matches && root.matches(selector)) { 110 fn(root); 111 } 112 Array.prototype.forEach.call(root.querySelectorAll(selector), fn); 113 } 114 115 // Sammelt die Fundstellen unter root: Textknoten (werden zum Link) und Elemente, 116 // hinter die ein einzelnes "?" kommt (mode 'after') bzw. an deren Ende (mode 'append'). 117 function collect(root) { 118 var found = { texts: [], solos: [] }; 119 if (root.nodeType === 3) { 120 if (acceptText(root)) { 121 found.texts.push(root); 122 } 123 return found; 124 } 125 if (root.nodeType !== 1 || isOwn(root)) { 126 return found; 127 } 128 129 var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false); 130 var node; 131 while ((node = walker.nextNode())) { 132 if (acceptText(node)) { 133 found.texts.push(node); 134 } 135 } 136 137 each(root, '[class*="icon-fsk-"]', function (el) { 138 var m = /(?:^|\s)icon-fsk-(\d+)(?:\s|$)/.exec(el.className || ''); 139 if (m && VALUES[m[1]]) { 140 found.solos.push({ el: el, mode: 'after' }); 141 } 142 }); 143 each(root, 'span.fsk', function (el) { 144 if (VALUES[(el.textContent || '').replace(/\s+/g, '')]) { 145 found.solos.push({ el: el, mode: 'after' }); 146 } 147 }); 148 each(root, 'img[src*="/pictogramme/"]', function (img) { 149 if (/\/(?:0|6|12|16|18)\.png(?:[?#]|$)/.test(img.getAttribute('src') || '')) { 150 found.solos.push({ el: img.closest('a') || img, mode: 'after' }); 151 } 152 }); 153 each(root, '#fsk .value', function (el) { 154 if (/\d/.test(el.textContent || '')) { 155 found.solos.push({ el: el, mode: 'append' }); 156 } 157 }); 158 // alte Mobilseite: "freigegeben ab <span class="detailFsk">12</span> Jahren<span class="zusatzFSK">" 159 each(root, '.detailFsk', function (el) { 160 if (VALUES[(el.textContent || '').replace(/\s+/g, '')]) { 161 var extra = el.parentNode && el.parentNode.querySelector('.zusatzFSK'); 162 found.solos.push({ el: extra || el, mode: 'after' }); 163 } 164 }); 165 return found; 166 } 167 168 // ---------------------------------------------------- nextGen-Symbol (Icon-Schrift) 169 // Das FSK-Symbol ist ein Zeichen der Icon-Schrift: rechts neben dem schwarzen Kaestchen 170 // liegt Leerraum im Zeichen (Lux: 10,8 px), und das alte CSS schiebt es mit 171 // position:relative; left:-5px nach links. Beides wird gemessen, nicht angenommen. 172 173 var GAP = 6; // gewuenschter Abstand in px: Doppelpunkt -> Symbol und Symbol -> "?" 174 var measureCtx = null; 175 176 function isIcon(el) { 177 return /(?:^|\s)icon-fsk-\d+(?:\s|$)/.test(el.className || ''); 178 } 179 180 // "Altersfreigabe: [Symbol]" - steht direkt davor ein Text mit Doppelpunkt? 181 function hasLabelBefore(el) { 182 var prev = el.previousSibling; 183 if (prev && prev.nodeType === 3 && /:\s*$/.test(prev.nodeValue)) { 184 return true; 185 } 186 if (prev && prev.nodeType === 3 && !prev.nodeValue.trim()) { 187 prev = prev.previousSibling; 188 } 189 return !!(prev && prev.nodeType === 1 && /:\s*$/.test(prev.textContent || '')); 190 } 191 192 // Leerraum rechts neben dem sichtbaren Symbol bis zum Ende seines Kastens (+ Verschiebung). 193 function iconRightSpace(el) { 194 var style = window.getComputedStyle(el); 195 var shift = 0; 196 if (style.position === 'relative') { 197 var left = parseFloat(style.left); 198 var right = parseFloat(style.right); 199 shift = !isNaN(left) ? left : (!isNaN(right) ? -right : 0); 200 } 201 var bearing = 0; 202 try { 203 var before = window.getComputedStyle(el, '::before'); 204 var glyph = (before.content || '').replace(/^["']|["']$/g, ''); 205 if (glyph && glyph !== 'none' && glyph !== 'normal' && glyph.length <= 2) { 206 measureCtx = measureCtx || document.createElement('canvas').getContext('2d'); 207 measureCtx.font = before.fontStyle + ' ' + before.fontWeight + ' ' + before.fontSize + ' ' + before.fontFamily; 208 var metrics = measureCtx.measureText(glyph); 209 if (typeof metrics.actualBoundingBoxRight === 'number' && metrics.actualBoundingBoxRight > 0) { 210 bearing = Math.max(0, metrics.width - metrics.actualBoundingBoxRight); 211 } 212 } 213 } catch (err) { 214 bearing = 0; 215 } 216 return bearing - shift; 217 } 218 219 function placeIconLink(icon, link) { 220 if (hasLabelBefore(icon)) { 221 icon.classList.add('cp-fsk-icon-label'); // hebt left:-5px auf, etwas Luft zum Doppelpunkt 222 } 223 // Der "?"-Kreis (18 px) sitzt mittig im 24-px-Link: 3 px Innenabstand links. 224 var margin = GAP - 3 - iconRightSpace(icon); 225 link.style.marginLeft = Math.round(margin * 10) / 10 + 'px'; 226 } 227 228 function whenFontsReady(fn) { 229 if (document.fonts && document.fonts.ready) { 230 document.fonts.ready.then(fn, fn); 231 } else { 232 fn(); 233 } 234 } 235 236 function apply(found) { 237 var icons = []; 238 found.solos.forEach(function (item) { 239 var el = item.el; 240 if (!el.parentNode || isOwn(el)) { 241 return; 242 } 243 if (item.mode === 'append') { 244 if (!el.querySelector('.cp-fsk-link')) { 245 el.appendChild(document.createTextNode(' ')); 246 el.appendChild(makeLink('')); 247 } 248 return; 249 } 250 if (!hasLinkAfter(el)) { 251 var link = makeLink(''); 252 el.parentNode.insertBefore(link, el.nextSibling); 253 if (isIcon(el)) { 254 icons.push([el, link]); 255 } 256 } 257 }); 258 if (icons.length) { 259 // Erst messen, wenn die Icon-Schrift geladen ist - sonst misst der Canvas eine Ersatzschrift. 260 whenFontsReady(function () { 261 icons.forEach(function (pair) { 262 placeIconLink(pair[0], pair[1]); 263 }); 264 }); 265 }
266 found.texts.forEach(function (node) { 267 if (node.parentNode && acceptText(node)) { 268 wrapText(node); 269 } 270 }); 271 } 272 273 function process(root) { 274 var found = collect(root); 275 if (found.texts.length || found.solos.length) { 276 loadCss().then(function () { 277 apply(found); 278 }); 279 } 280 } 281 282 function flush() { 283 queued = false; 284 var nodes = queue; 285 queue = []; 286 nodes.forEach(function (node) { 287 if (node.isConnected !== false) { 288 process(node); 289 } 290 }); 291 } 292 293 var observer = new MutationObserver(function (records) { 294 records.forEach(function (record) { 295 Array.prototype.forEach.call(record.addedNodes, function (node) { 296 if (node.nodeType === 1) { 297 queue.push(node); 298 } else if (node.nodeType === 3) { 299 // Zahl nachtraeglich in ein Element geschrieben (z. B. .html('12')): das Element pruefen 300 queue.push(node.parentElement && node.parentElement !== document.body ? node.parentElement : node); 301 } 302 }); 303 }); 304 if (queue.length && !queued) { 305 queued = true; 306 // setTimeout statt requestAnimationFrame: laeuft auch in einem gerade unsichtbaren Tab 307 window.setTimeout(flush, 30); 308 } 309 }); 310 311 // ---------------------------------------------------------------- Fenster 312 313 function isPremium() { 314 var value = window.getComputedStyle(document.documentElement).getPropertyValue('--color-ink'); 315 return !!(value && value.trim()); 316 } 317 318 function buildDialog() { 319 dialog = document.createElement('dialog'); 320 dialog.className = 'cp-fsk-dialog' + (isPremium() ? ' cp-fsk-dialog--premium' : ''); 321 dialog.setAttribute('aria-labelledby', 'cp-fsk-dialog-title'); 322 dialog.innerHTML = 323 '<div class="cp-fsk-dialog__inner">' + 324 '<div class="cp-fsk-dialog__head">' + 325 '<h2 class="cp-fsk-dialog__title" id="cp-fsk-dialog-title">' + TITLE + '</h2>' + 326 '<button type="button" class="cp-fsk-dialog__close" aria-label="SchlieÃen"><span aria-hidden="true">Ã</span></button>' + 327 '</div>' + 328 '<div class="cp-fsk-dialog__body"><p class="cp-fsk-dialog__status">Einen Moment â¦</p></div>' + 329 '</div>'; 330 document.body.appendChild(dialog); 331 332 dialog.querySelector('.cp-fsk-dialog__close').addEventListener('click', function () { 333 dialog.close(); 334 }); 335 // Esc selbst behandeln und nicht weiterreichen: Overlays darunter (Premium-Schnellansicht, 336 // Menue, Suche) hoeren auf document und verhindern sonst das Schliessen - oder schliessen mit. 337 dialog.addEventListener('keydown', function (event) { 338 if (event.key === 'Escape' || event.key === 'Esc' || event.keyCode === 27) { 339 event.preventDefault(); 340 event.stopPropagation(); 341 dialog.close(); 342 } 343 }); 344 // Klick neben das Fenster (auf den abgedunkelten Hintergrund) schliesst es. 345 dialog.addEventListener('click', function (event) { 346 if (event.target === dialog) { 347 dialog.close(); 348 } 349 }); 350 dialog.addEventListener('close', function () { 351 document.documentElement.classList.remove('cp-fsk-open'); 352 if (lastTrigger && lastTrigger.isConnected !== false && lastTrigger.focus) { 353 try { 354 lastTrigger.focus({ preventScroll: true }); 355 } catch (err) { 356 lastTrigger.focus(); 357 } 358 } 359 }); 360 } 361 362 function loadContent() { 363 if (!contentPromise) { 364 contentPromise = window.fetch(CONTENT_URL, { credentials: 'same-origin' }) 365 .then(function (response) { 366 if (!response.ok) { 367 throw new Error('HTTP ' + response.status); 368 } 369 return response.text(); 370 }) 371 .then(function (html) { 372 var doc = new window.DOMParser().parseFromString(html, 'text/html'); 373 var box = doc.querySelector('.cp-fsk'); 374 if (!box) { 375 throw new Error('Inhalt fehlt'); 376 } 377 return document.importNode(box, true); 378 }); 379 contentPromise.catch(function () { 380 contentPromise = null; 381 }); 382 } 383 return contentPromise; 384 } 385 386 function open(trigger) { 387 if (!dialog) { 388 buildDialog(); 389 } 390 if (typeof dialog.showModal !== 'function' || !window.fetch || !window.DOMParser) { 391 window.location.href = CONTENT_URL; 392 return; 393 } 394 lastTrigger = trigger; 395 var body = dialog.querySelector('.cp-fsk-dialog__body'); 396 if (!dialog.open) { 397 document.documentElement.classList.add('cp-fsk-open'); 398 dialog.showModal(); 399 } 400 body.scrollTop = 0; 401 if (body.querySelector('.cp-fsk')) { 402 return; 403 } 404 Promise.all([loadCss(), loadContent()]).then(function (result) { 405 body.innerHTML = ''; 406 body.appendChild(result[1]); 407 body.scrollTop = 0; 408 }).catch(function () { 409 body.innerHTML = '<p class="cp-fsk-dialog__status">Die Ãbersicht lässt sich gerade nicht laden. ' + 410 '<a href="' + CONTENT_URL + '">Hier öffnen</a></p>'; 411 }); 412 } 413 414 // Capture-Phase: Der Link sitzt oft in klickbaren Karten (Schnellansicht, Detail-Link) - 415 // die sollen beim "?" nicht mitreagieren. 416 document.addEventListener('click', function (event) { 417 var target = event.target; 418 var link = target && target.closest ? target.closest('[data-fsk-info]') : null; 419 if (!link) { 420 return; 421 } 422 event.preventDefault(); 423 event.stopPropagation(); 424 open(link); 425 }, true); 426
427 function init() { 428 process(document.body); 429 observer.observe(document.body, { childList: true, subtree: true }); 430 } 431 432 if (document.readyState === 'loading') { 433 document.addEventListener('DOMContentLoaded', init); 434 } else { 435 init(); 436 } 437})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.