Auf vielen Webseiten fallen mir Kleinigkeiten auf, die verbessert werden könnten; meist Schreibfehler, zum Teil auch inhaltliche Fehler oder falsche Verlinkungen. Aber in der Regel habe ich keine Lust, die E-Mailadresse eines Ansprechpartners zu suchen und einen Text für eine Rückmeldung zu schreiben – das typische Problem eines Medienbruchs. Daher hatte ich für meine Seite schon lange den Wunsch, dass Fehler leicht zu melden gehen.

Meine Vorstellung ist, dass man den betreffenden Textteil markieren, dann in einem Dialog vielleicht noch eine Anmerkung einfügen kann und fertig. Im einfachsten Fall sollte die Fehlermeldung mit zwei oder drei Mausklicks erledigt sein. Im Hintergrund sollten die Informationen möglichst gut aufbereitet werden, sodass die Bearbeitung der Fehlermeldungen angenehm leicht wird. Die Vorstellung dafür – aber davon bin ich noch weit entfernt – ist eine richtige Webseite, die einem dabei unterstützt.

Javascript bietet eine API für Textmarkierungen und ein passendes Ereignis selectionchange über das der Browser mitteilt, dass sich die Markierung verändert hat. Für eine Markierung bekommt man einen Bereich geliefert, über den man die Position im Quelltext ermitteln kann. Sollte sich bei der Erstellung des HTMLs die Möglichkeit ergeben, eine Zuordnung zum Markdown zu schaffen, könnte also direkt die entsprechende Position im Markdown angegeben werden. Aber für den Anfang genügt es, zu wissen, wo auf der Seite etwas markiert wurde, denn ein »die«, das »der« seien muss, ist schwer zu finden.

Die Praxis hat sich die Reaktion auf die Markierungsänderung aber als etwas komplizierter herausgestellt, denn die Markierung ändert sich vom ersten Zeichen an und wenn sich dann ein Dialog öffnet, kann man nichts mehr markieren. Unter einigen Umständen hat auch das Öffnen des Dialogs zum Verlust der Markierung geführt, weshalb ich die Zwischenstufe eingebaut habe: In der Nähe des Mauszeigers wird beim Markieren ein kleiner Knopf angezeigt, bei dessen Überfliegen oder Drücken sich erst der Dialog öffnet.

Ein Dialog

Für HTML5 gibt es ein <dialog>-Element, das schon einige hilfreiche Funktionen wie open und close bietet. Am Ende habe ich im HTML am Ende ein passendes Formular eingefügt:

<button id=feedback-button hidden>Fehler gefunden?</button>
<dialog id=feedback-dialog autocomplete=off>
  <form method=POST id=feedback-form action='/api/unstable/feedback'>
  …
</form>
</dialog>

</body>
</html>

Von dem, was HTML vorsieht, wird der Dialog leider nicht geschlossen, wenn man daneben klickt. Mir ist dies mit folgendem click-Handler gelungen:

const dlg = document.getElementById('feedback-dialog');
dlg.addEventListener('click', function (ev) {
    // close (cancel) the dialog, if the user clicks outside of the dialog
    const tgt = ev.target;
    if (!tgt.open)
        return;
    const r = tgt.getBoundingClientRect();
    if (ev.x < r.left || ev.x > r.right || ev.y < r.top || ev.y > r.bottom)
        tgt.close();
});

Dialog für Firefox nachrüsten

Leider unterstützt Firefox 79 noch nicht <dialog>, weshalb ich mit einem Polyfill diese Funktionalität ggf. nachrüste. Der Code hier nutzt dynamische Imports um den Code wirklich nur bei Bedarf zu laden.

if (!window.HTMLDialogElement) {
    const dlg = document.getElementsByTagName('dialog');
    if (dlg.length > 0 && !dlg[0].showModal) {
        import('./lib/polyfil-dialog/index.js')
            .then((mod) => {
                mod = mod.default;
                Array.prototype.forEach.call(dlg, (el) => { mod.registerDialog(el); });
            });

        const link = document.createElement('link');
        link.rel = "stylesheet";
        link.type = "text/css";
        link.media = "screen";
        link.href = document.head.querySelector('link[rel=home]').getAttribute('href')
            + '/lib/polyfil-dialog/dialog-polyfill.css';
        document.head.append(link);
    }
}

Ein Knopf zum Abschalten

Schon beim Programmieren ist mir aufgefallen, dass diese Funktionalität auch stören kann, weshalb ich es dann so gebaut habe, dass es in der Kopfzeile einen Knopf zum An- und Abschalten gibt.

Für den Knopf habe ich noch kein eigenes Bild, weshalb ich ihn mit HTML-Mitteln gebastelt habe, so dass sich auch gut das Aussehen mit CSS verändern lässt. Anfangs hatte ich die Kombination <input id="feedback-toggle" type=checkbox hidden><label for="feedback-toggle">✎</label>, womit sich das label-Element im CSS über input:checked + label adressieren lässt, um im aktivierten Zustand die Darstellung anzupassen. Aber bei diesem Checkbox-Hack lässt sich das <label> nicht mit der Tastatur anspringen und bedienen.

Deshalb nutze ich jetzt <button>✎</button> und setze mit Javascript das Attribut checked, denn mit CSS lassen sich auch beliebige Attribute prüfen; button[checked] { … }. Leider sind bei einem Button so viele Einstellungen vom Browser vorgenommen, dass es schwierig ist alle einzeln anzupassen. Deshalb nutze ich button { all: unset; … }, um alles in den Grundzustand zu versetzen, und im Anschluss die gewünschten Anpassungen vorzunehmen.

$0.onclick = (ev) => {
    const btn = ev.target;
    if (btn.getAttribute('checked') == null)
        btn.setAttribute('checked', '');
    else
        btn.removeAttribute('checked');
}

Ein simpler Empfänger mit Nginx

Für Nginx hatte ich mal eine geschickte Möglichkeit zum Speichern von POST-Daten gefunden, die ich auch hier für die erste Version genutzt habe. Aber dies lässt sich auch schön mit einem eigenen Webservice lösen, der in eine Datenbank schreibt.

Da viele Spammer durch Netz wandern und sinnlos alle HTML-Formulare mit ihrem Müll befüllen, ist die Prüfung des Content-Types hilfreich, da dieser erst mit Javascript gesetzt wird und nicht im HTML-Code enthalten ist. Da Nginx aber nur eine Prüfung pro if unterstützt muss der gesamte Code wie in Funktionen mit early-return formuliert werden.

log_format postdata escape=json '{"time":"$time_iso8601","referer":"$http_referer","userAgent":"$http_user_agent","body":"$request_body"}';

server {
    …
    location = /api/unstable/feedback {
        if ($request_method != POST) {
            return 400;
        }
        if ($http_content_type != 'application/json') {
            return 400;
        }

        access_log /var/log/nginx/feedback.log postdata;
        echo_status 204;
        echo_read_request_body;
    }

Der Code

Hier folgt der gesamte Code, der gern kopiert und genutzt werden kann, damit in möglichst vielen Webseiten die Fehlermeldung erleichtert wird. Ich habe versucht den Code mit Web components entsprechend zu gliedern, sodass im HTML die Formatierung und in einer Javascript-Datei die Funktionalität liegt.

Irgendwann werde ich den Code auch in ein richtiges Modul packen, dass man leicht einbinden kann. Sinnvoll wäre vielleicht noch, das submit-Ereignis als eigenes Ereignis des Dialogs nach außen zu reichen, damit dieser Teil auch noch ohne Eingriffe in den Code anpassbar wird.

HTML und CSS

<body>
<header>
  …
  <span id=header-buttons
    ><button hidden id="feedback-toggle"
      title="Dialog für Rückmeldungen bei Textmarkierung aktivieren">✎</button>
  …
</header>
…
<template id=feedback-pre-dialog-t>
<style>
:host {
  position: fixed;
  z-index: 100;
}
button {
  padding: 6px;
}
</style>
<button>Fehler gefunden?</button>
</template>

<template id=feedback-dialog-t>
<style>
ul {
  all: unset;
  list-style: none;
}

#selection-text {
  display: inline-block;
  font-style: italic;
  max-width: 20em;
  overflow-x: hidden;
  text-overflow: ellipsis;
  vertical-align: text-bottom;
  white-space: nowrap;
}

label {
  display: block;
}

fieldset {
  box-sizing: border-box;
  width: 100%;
}

textarea {
  display: block;
  width: 100%;
}
</style>
<form method=POST id=form action='/api/unstable/feedback'>
  <label form=form>Rückmeldung für »<span id=selection-text></span>«</label>

  <fieldset>
    <legend>Art</legend>
    <ul>
      <li><label><input type=radio name=kind value=spelling checked>&nbsp;Rechtschreibfehler</label>
      <li><label><input type=radio name=kind value=suggestion>&nbsp;Verbesserungsvorschlag</label>
      <li><label><input type=radio name=kind value=addition>&nbsp;Ergänzung</label>
      <li><label><input type=radio name=kind value=misc>&nbsp;Sonstiges</label>
    </ul>
  </fieldset>

  <label for=data>Korrekturvorschlag:</label>
  <textarea name=text id=data></textarea>

  <input type=hidden name=selection />
  <input type=hidden name=position />

  <button type=submit>Eingaben absenden</button>
</form>
</template>
</body>

Javascript

/* tools {{{ */
function readInputFields(fields) {
    return Array.prototype.reduce.call(fields, function (data, el) {
        if (el.disabled)
            return data;

        let name = el.name;
        const toArray = name.endsWith('[]');
        if (toArray)
            name = name.substr(0, name.length - 2);
        if (name === '')
            return data;

        let val;
        switch (el.type) {
        case 'checkbox':
            if (toArray && el.hasAttribute('value')) {
                if (el.checked) {
                    val = el.getAttribute('value');
                    try { val = JSON.parse(val); } catch (e) { }
                }
            } else
                val = el.checked;
            break;

        case 'date':
            if (el.value != '') {
                if ('valueAsDate' in el)
                    val = el.valueAsDate;
                else
                    try { val = new Date(el.value); } catch (e) { }
            }
            break;

        case 'radio':
            if (!el.checked || el.value === '')
                break;

            val = el.value;
            try { val = JSON.parse(val); } catch (e) { }
            break;

        case 'select-one':
            if (el.selectedIndex != -1) {
                val = el.options[el.selectedIndex].value;
                try { val = JSON.parse(val); } catch (e) { }
            }
            break;

        case 'select-multiple':
            val = Array.prototype.reduce.call(el.options, function (data, opt) {
                if (opt.selected) {
                    var v = opt.value;
                    try { v = JSON.parse(v); } catch (e) { }
                    data.push(v);
                }
                return data
            }, []);
            break;

        default:
            val = el.value;
            break;
        }

        if (val !== undefined)
            data[name] = val;

        return data;
    }, {});
}

function cssPath(el) {
    if (!(el instanceof Element))
        return undefined;

    const path = [];
    while (el.nodeType === Node.ELEMENT_NODE) {
        let selector = el.nodeName.toLowerCase();
        if (el.id) {
            selector += '#' + el.id;
            path.unshift(selector);
            break;
        }

        let sib = el, nth = 1;
        while ( (sib = sib.previousElementSibling) ) {
            if (sib.nodeName === el.nodeName)
                nth++;
        }
        if (nth != 1)
            selector += ":nth-of-type(" + nth + ")";

        path.unshift(selector);
        el = el.parentNode;
    }

    return path.join(" > ");
}
/* }}} tools */

/* feedback {{{ */
const initFeedback = (function() {

function initFeedback(toggleButton) {
    toggleButton.addEventListener('click', toggleFeedback);
    // do all the setup upon the first click
    toggleButton.addEventListener('click', setup, { once: true });
}

function setup() {
    const pre_templ = document.getElementById('feedback-pre-dialog-t').content;
    class FeedbackPreDialog extends HTMLElement {
        constructor() {
            super();
            this.attachShadow({mode: 'open'})
                .appendChild(pre_templ.cloneNode(true));
        }
    }

    window.customElements.define('feedback-pre-dialog', FeedbackPreDialog);

    let polyfillDialog;
    let polyfillCss;
    if (!window.HTMLDialogElement) {
        import('./lib/polyfill-dialog/index.js')
            .then(mod => { polyfillDialog = mod.default; });

        polyfillCss = document.createElement('link');
        const link = document.createElement('link');
        link.rel = "preload";
        polyfillCss.rel = link.as = "stylesheet";
        polyfillCss.type = link.type = "text/css";
        polyfillCss.href = link.href
            = document.head.querySelector('link[rel=home]').getAttribute('href')
            + '/lib/polyfill-dialog/dialog-polyfill.css';
        document.head.append(link);
    }

    const template = document.getElementById('feedback-dialog-t').content;
    class FeedbackDialog extends HTMLElement {
        static get observedAttributes() {
            return ['selection-text', 'selection-position'];
        }

        constructor() {
            super();

            const dlg = document.createElement('dialog');
            if (polyfillDialog) {
                polyfillDialog.registerDialog(dlg);
            }

            dlg.addEventListener('click', function (ev) {
                // close (cancel) the dialog, if the user clicks outside of the dialog
                const tgt = ev.currentTarget;
                const r = tgt.getBoundingClientRect();
                if (ev.x < r.left || ev.x > r.right || ev.y < r.top || ev.y > r.bottom)
                    tgt.getRootNode().host.remove();
            });
            dlg.appendChild(template.cloneNode(true));

            dlg.querySelector('form').addEventListener('submit', submitFeedback);

            const sr = this.attachShadow({mode: 'open'});
            sr.appendChild(dlg);
            if (polyfillCss)
                sr.appendChild(polyfillCss.cloneNode(true));
        }

        attributeChangedCallback(name, oldVal, newVal) {
            if (oldVal === newVal)
                return;

            const form_els = this.shadowRoot.querySelector('form').elements;
            switch (name) {
            case 'selection-text':
                form_els.selection.value = newVal;
                this.shadowRoot.getElementById('selection-text')
                    .innerText = newVal.replace(/\s+/g, ' ');
                break;
            case 'selection-position':
                form_els.position.value = newVal;
                break;

            default:
                console.error(`Unknown property name: (${name}, ${oldVal}, ${newVal})`);
                break;
            }
        }

        connectedCallback() {
            this.setAttribute('role', 'dialog');
            this.shadowRoot.firstChild.showModal();
        }

        updateBySelection(sel) {
            if (!sel)
                sel = window.getSelection();

            this.setAttribute('selection-text', sel.toString());

            let anchor = sel.anchorNode;
            let pos;
            if (anchor instanceof Element) {
                pos = cssPath(anchor);
            } else {
                anchor = anchor.parentNode;
                pos = cssPath(anchor) + ' > innerText';
            }
            pos += '+' + sel.anchorOffset;
            this.setAttribute('selection-position', pos);

            return this;
        }
    };

    window.customElements.define('feedback-dialog', FeedbackDialog);
}

function toggleFeedback(ev) {
    if (this.getAttribute('checked') == null) {
        this.setAttribute('checked', '');
        document.addEventListener('selectionchange', selectionChanged);
        console.debug('feedback dialog enabled');
    } else {
        this.removeAttribute('checked');
        document.removeEventListener('selectionchange', selectionChanged);
        document.querySelectorAll('feedback-dialog, feedback-pre-dialog')
            .forEach(el => el.remove());

        console.debug('feedback dialog disbaled');
    }
}

function selectionChanged(ev) {
    let pre = document.querySelector('feedback-pre-dialog');
    const sel = window.getSelection();

    if (sel.isCollapsed) {
        if (pre)
            pre.remove();

        return;
    }

    if (!pre) {
        pre = document.createElement('feedback-pre-dialog');
        pre.onclick = pre.onmouseover = showDialog;
    }

    // TODO: prüfen, ob Markierung von rechts nach links geht,
    // d.&#8239;h. sel.focus ist vor sel.anchor
    const rect = Array.prototype.reduce.call(sel.getRangeAt(0).getClientRects(),
      (max, el) => {
        if (max === undefined)
            return el;
        if (max.top < el.top)
            return el;
        if (max.top > el.top)
            return max;
        if (max.right < el.right)
            return el;
        return max;
      });

    const gap_to_prevent_hover = 20;
    pre.style.left = Math.floor(window.pageXOffset + rect.right + gap_to_prevent_hover) + 'px';
    pre.style.top = Math.floor(window.pageYOffset + rect.top) + 'px';

    if (!pre.isConnected) {
        document.body.append(pre);
    }
}

function showDialog(ev) {
    document.body.appendChild(
        document.createElement('feedback-dialog')
        .updateBySelection()
    );

    ev.target.remove();
}

function submitFeedback(ev) {
    ev.preventDefault();
    const form = ev.currentTarget;
    const dlg = form.getRootNode().host;

    const data = readInputFields(form.elements);
    data.location = window.location.toString();

    const xhr = new XMLHttpRequest();
    xhr.open("POST", form.action, true);
    xhr.setRequestHeader("Content-type", "application/json");
    xhr.addEventListener("load", (ev) => {
        const xhr = ev.target;
        if (xhr.status === 204) {
            console.info('Feedback submitted');
            dlg.remove();
        } else {
            console.error(`Submitting feedback failed (${xhr.status}): ${xhr.response}`);
        }
    });
    xhr.addEventListener("error", (ev) => {
        console.error('Submitting feedback failed: ', ev);
    });
    xhr.send(JSON.stringify(data));
}

    return initFeedback;
})();

/* }}} feedback */

function onDomLoad(ev) {
    const fbt = document.getElementById('feedback-toggle');
    if (fbt != undefined) {
        fbt.hidden = false;
        initFeedback(fbt);
    }
}

if (!window.matchMedia("print").matches) {
    if (document.readyState === 'loading')
        document.addEventListener('DOMContentLoaded', onDomLoad);
    else
        onDomLoad();
}