OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

vault-actions.js

7 KB
/**
 * Ereignisverteiler für die Tresor-Seiten.
 *
 * Auf `/vault…` gilt eine Content-Security-Policy ohne 'unsafe-inline': Ein in
 * die Seite geratener HTML-Schnipsel wird dort nicht zu ausgeführtem Code. Der
 * Preis dafür ist, dass `onclick="…"` und Verwandte nicht mehr laufen – ein
 * Nonce deckt Ereignisattribute nicht ab. Die Vorlagen beschreiben ihr Verhalten
 * deshalb über data-Attribute, die hier einmal zentral verdrahtet werden.
 *
 * Aufgelöst wird beim Klick immer das **innerste** Element mit einem bekannten
 * Attribut. Ein Kopier-Knopf in einer anklickbaren Zeile gewinnt damit gegen die
 * Zeile, ohne dass irgendwo `stopPropagation()` nötig wäre.
 *
 * Vokabular (Klick):
 *   data-act="fn"            Funktion aufrufen: fn(...args, el, event); `this` ist el.
 *                            Gibt sie false zurück, wird die Vorgabeaktion unterdrückt.
 *   data-args='[1,"x"]'      Argumente davor (JSON-Array)
 *   data-confirm="Frage"     erst nach Rückfrage; auch an <form> für das Absenden
 *   data-open="id"           Element mit dieser Kennung öffnen (Klasse „open")
 *   data-close="id"          Element mit dieser Kennung schließen
 *   data-close-self          nur beim Klick auf das Element selbst schließen (Dialoghintergrund)
 *   data-toggle-parent="cls" Klasse am Elternelement umschalten
 *   data-drop-class="cls"    Klasse am Element selbst entfernen
 *   data-remove-closest="s"  nächstgelegenes passendes Element entfernen
 *   data-copy="text"         Text kopieren – über die Seitenfunktion vaultCopyText(text, el)
 *   data-href="/pfad"        dorthin wechseln
 *
 * Weitere Ereignisse:
 *   data-act-change="fn"     beim change-Ereignis
 *   data-act-input="fn"      beim input-Ereignis
 *   data-act-submit="fn"     beim Absenden eines Formulars
 *   data-favicon-fallback    Bild: schlägt das Laden fehl, das folgende Element einblenden
 */
(function () {
    'use strict';

    var SELEKTOR = '[data-act],[data-open],[data-close],[data-close-self],[data-toggle-parent],'
                 + '[data-drop-class],[data-remove-closest],[data-copy],[data-href],[data-stop]';

    /** Argumente eines Elements; eine fehlerhafte Angabe gilt als „keine". */
    function args(el) {
        var roh = el.getAttribute('data-args');
        if (!roh) { return []; }
        try {
            var wert = JSON.parse(roh);
            return Array.isArray(wert) ? wert : [wert];
        } catch (e) {
            return [];
        }
    }

    /**
     * Ruft die benannte Funktion auf. Sie muss global stehen – die Vorlagen
     * definieren sie in ihrem eigenen Skriptblock.
     * @returns {*} Rückgabewert, damit `false` die Vorgabeaktion unterdrücken kann
     */
    function rufe(name, el, ereignis) {
        var fn = name && window[name];
        return typeof fn === 'function'
            ? fn.apply(el, args(el).concat([el, ereignis]))
            : undefined;
    }

    function oeffne(id) {
        var ziel = id && document.getElementById(id);
        if (ziel) { ziel.classList.add('open'); }
    }

    function schliesse(id) {
        var ziel = id && document.getElementById(id);
        if (ziel) { ziel.classList.remove('open'); }
    }

    document.addEventListener('click', function (e) {
        var el = e.target.closest(SELEKTOR);
        if (!el) { return; }

        // Reiner Halt: verhindert, dass ein umschließendes Element (etwa eine
        // anklickbare Zeile) auf diesen Klick reagiert.
        if (el.hasAttribute('data-stop') && !el.hasAttribute('data-act')) { return; }

        // Dialoghintergrund: nur schließen, wenn der Klick wirklich ihm galt und
        // nicht dem Inhalt darin.
        if (el.hasAttribute('data-close-self')) {
            if (e.target === el) { el.classList.remove('open'); }
            return;
        }

        var frage = el.getAttribute('data-confirm');
        if (frage && !window.confirm(frage)) {
            e.preventDefault();
            return;
        }

        if (el.hasAttribute('data-open')) {
            oeffne(el.getAttribute('data-open'));
            e.preventDefault();
            return;
        }
        if (el.hasAttribute('data-close')) {
            schliesse(el.getAttribute('data-close'));
            e.preventDefault();
            return;
        }
        if (el.hasAttribute('data-toggle-parent')) {
            if (el.parentNode && el.parentNode.classList) {
                el.parentNode.classList.toggle(el.getAttribute('data-toggle-parent'));
            }
            return;
        }
        if (el.hasAttribute('data-drop-class')) {
            el.classList.remove(el.getAttribute('data-drop-class'));
            return;
        }
        if (el.hasAttribute('data-remove-closest')) {
            var weg = el.closest(el.getAttribute('data-remove-closest'));
            if (weg) { weg.remove(); }
            return;
        }
        if (el.hasAttribute('data-copy')) {
            if (typeof window.vaultCopyText === 'function') {
                window.vaultCopyText(el.getAttribute('data-copy'), el);
            }
            return;
        }
        if (el.hasAttribute('data-act')) {
            if (rufe(el.getAttribute('data-act'), el, e) === false) {
                e.preventDefault();
            }
            return;
        }
        if (el.hasAttribute('data-href')) {
            window.location = el.getAttribute('data-href');
        }
    });

    document.addEventListener('change', function (e) {
        var el = e.target.closest('[data-act-change]');
        if (el) { rufe(el.getAttribute('data-act-change'), el, e); }
    });

    document.addEventListener('input', function (e) {
        var el = e.target.closest('[data-act-input]');
        if (el) { rufe(el.getAttribute('data-act-input'), el, e); }
    });

    document.addEventListener('submit', function (e) {
        var form = e.target;
        if (!form || !form.getAttribute) { return; }

        var frage = form.getAttribute('data-confirm');
        if (frage && !window.confirm(frage)) {
            e.preventDefault();
            return;
        }
        if (rufe(form.getAttribute('data-act-submit'), form, e) === false) {
            e.preventDefault();
        }
    });

    /**
     * Favicon-Ersatz: Lädt das Bild nicht, wird das unmittelbar folgende
     * Element eingeblendet. Ein Fehler kann schon vor dem Laden dieses Skripts
     * eingetreten sein – deshalb wird der Zustand zusätzlich einmal geprüft.
     */
    function faviconErsatz(img) {
        var ph = img.nextElementSibling;
        img.style.display = 'none';
        if (!ph) { return; }
        ph.hidden = false;
        if (img.parentNode && img.parentNode.style) {
            img.parentNode.style.background = ph.getAttribute('data-bg') || '';
        }
    }

    function faviconsVerdrahten(wurzel) {
        (wurzel || document).querySelectorAll('img[data-favicon-fallback]').forEach(function (img) {
            if (img.dataset.faviconWired === '1') { return; }
            img.dataset.faviconWired = '1';
            img.addEventListener('error', function () { faviconErsatz(img); });
            if (img.complete && img.naturalWidth === 0) { faviconErsatz(img); }
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { faviconsVerdrahten(); });
    } else {
        faviconsVerdrahten();
    }

    // Für Vorlagen, die dieselben Dialoge auch aus eigenem Code steuern oder
    // Inhalte nachladen.
    window.vaultOpen     = oeffne;
    window.vaultClose    = schliesse;
    window.vaultFavicons = faviconsVerdrahten;
})();